autoglm-gui 1.5.4__py3-none-any.whl → 1.5.6__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 (41) hide show
  1. AutoGLM_GUI/agents/glm/async_agent.py +3 -50
  2. AutoGLM_GUI/agents/protocols.py +3 -14
  3. AutoGLM_GUI/api/agents.py +118 -93
  4. AutoGLM_GUI/api/devices.py +177 -0
  5. AutoGLM_GUI/api/scheduled_tasks.py +7 -2
  6. AutoGLM_GUI/device_group_manager.py +354 -0
  7. AutoGLM_GUI/models/__init__.py +8 -0
  8. AutoGLM_GUI/models/device_group.py +63 -0
  9. AutoGLM_GUI/models/scheduled_task.py +47 -4
  10. AutoGLM_GUI/scheduler_manager.py +255 -80
  11. AutoGLM_GUI/schemas.py +148 -3
  12. AutoGLM_GUI/static/assets/{about-BZglkj97.js → about-DVviVdH2.js} +1 -1
  13. AutoGLM_GUI/static/assets/{alert-dialog-5vNoxwIO.js → alert-dialog-IHmO2JCQ.js} +1 -1
  14. AutoGLM_GUI/static/assets/chat-C_3D0Ao7.js +134 -0
  15. AutoGLM_GUI/static/assets/{dialog-DSAhQHru.js → dialog-DOpd71Lu.js} +3 -3
  16. AutoGLM_GUI/static/assets/{eye-Deqw6dbm.js → eye-CZP5ZJ_Y.js} +1 -1
  17. AutoGLM_GUI/static/assets/folder-open-_KlT8ZW7.js +1 -0
  18. AutoGLM_GUI/static/assets/{history-CL-JjUbk.js → history-BXMlCwUV.js} +1 -1
  19. AutoGLM_GUI/static/assets/index-84TrNz7w.css +1 -0
  20. AutoGLM_GUI/static/assets/index-Bh2f556h.js +1 -0
  21. AutoGLM_GUI/static/assets/index-DUNWZsFq.js +11 -0
  22. AutoGLM_GUI/static/assets/{label-CEmK7RW4.js → label-CRZhpiYG.js} +1 -1
  23. AutoGLM_GUI/static/assets/logs-DEN9nDRS.js +1 -0
  24. AutoGLM_GUI/static/assets/popover-DitUZhUk.js +1 -0
  25. AutoGLM_GUI/static/assets/scheduled-tasks-boxDKe87.js +1 -0
  26. AutoGLM_GUI/static/assets/{textarea-nLU4tGQH.js → textarea-CvRHzjfV.js} +1 -1
  27. AutoGLM_GUI/static/assets/{workflows-QIA3_mdp.js → workflows-Bg3qN-6j.js} +1 -1
  28. AutoGLM_GUI/static/index.html +2 -2
  29. {autoglm_gui-1.5.4.dist-info → autoglm_gui-1.5.6.dist-info}/METADATA +361 -10
  30. {autoglm_gui-1.5.4.dist-info → autoglm_gui-1.5.6.dist-info}/RECORD +33 -31
  31. AutoGLM_GUI/static/assets/chat-ta_RqZfZ.js +0 -129
  32. AutoGLM_GUI/static/assets/circle-alert-CnwO7Du-.js +0 -1
  33. AutoGLM_GUI/static/assets/index-BjaUZM-7.js +0 -1
  34. AutoGLM_GUI/static/assets/index-CX4NAYCk.js +0 -11
  35. AutoGLM_GUI/static/assets/index-DSIMVL8V.css +0 -1
  36. AutoGLM_GUI/static/assets/logs-C-Pnb4jI.js +0 -1
  37. AutoGLM_GUI/static/assets/popover-CauTjrhB.js +0 -1
  38. AutoGLM_GUI/static/assets/scheduled-tasks-Ds1WrRVN.js +0 -1
  39. {autoglm_gui-1.5.4.dist-info → autoglm_gui-1.5.6.dist-info}/WHEEL +0 -0
  40. {autoglm_gui-1.5.4.dist-info → autoglm_gui-1.5.6.dist-info}/entry_points.txt +0 -0
  41. {autoglm_gui-1.5.4.dist-info → autoglm_gui-1.5.6.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,354 @@
1
+ """Device group management module.
2
+
3
+ Features:
4
+ - 单例模式
5
+ - JSON 文件持久化
6
+ - 基于 mtime 的缓存机制
7
+ - 原子文件写入
8
+ - 默认分组自动创建
9
+ """
10
+
11
+ import json
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+ from threading import RLock
15
+ from typing import Optional
16
+
17
+ from AutoGLM_GUI.logger import logger
18
+ from AutoGLM_GUI.models.device_group import (
19
+ DEFAULT_GROUP_ID,
20
+ DeviceGroup,
21
+ )
22
+
23
+
24
+ class DeviceGroupManager:
25
+ """设备分组管理器(单例模式)."""
26
+
27
+ _instance: Optional["DeviceGroupManager"] = None
28
+
29
+ def __new__(cls):
30
+ """单例模式:确保只有一个实例."""
31
+ if cls._instance is None:
32
+ cls._instance = super().__new__(cls)
33
+ return cls._instance
34
+
35
+ def __init__(self):
36
+ """初始化管理器."""
37
+ if hasattr(self, "_initialized"):
38
+ return
39
+ self._initialized = True
40
+ self._groups_path = Path.home() / ".config" / "autoglm" / "device_groups.json"
41
+ self._lock = RLock()
42
+
43
+ # 缓存
44
+ self._groups_cache: Optional[list[DeviceGroup]] = None
45
+ self._assignments_cache: Optional[dict[str, str]] = None
46
+ self._file_mtime: Optional[float] = None
47
+
48
+ def list_groups(self) -> list[DeviceGroup]:
49
+ """获取所有分组(按 order 排序).
50
+
51
+ Returns:
52
+ list[DeviceGroup]: 分组列表
53
+ """
54
+ with self._lock:
55
+ groups, _ = self._load_data()
56
+ return sorted(groups, key=lambda g: g.order)
57
+
58
+ def get_group(self, group_id: str) -> DeviceGroup | None:
59
+ """根据 ID 获取单个分组.
60
+
61
+ Args:
62
+ group_id: 分组 ID
63
+
64
+ Returns:
65
+ DeviceGroup | None: 分组数据,如果不存在则返回 None
66
+ """
67
+ with self._lock:
68
+ groups, _ = self._load_data()
69
+ return next((g for g in groups if g.id == group_id), None)
70
+
71
+ def create_group(self, name: str) -> DeviceGroup:
72
+ """创建新分组.
73
+
74
+ Args:
75
+ name: 分组名称
76
+
77
+ Returns:
78
+ DeviceGroup: 新创建的分组
79
+ """
80
+ with self._lock:
81
+ groups, assignments = self._load_data()
82
+
83
+ # 计算新的 order 值(放在最后)
84
+ max_order = max((g.order for g in groups), default=-1)
85
+ new_group = DeviceGroup(
86
+ name=name,
87
+ order=max_order + 1,
88
+ )
89
+
90
+ groups.append(new_group)
91
+ self._save_data(groups, assignments)
92
+ logger.info(f"Created device group: {name} (id={new_group.id})")
93
+ return new_group
94
+
95
+ def update_group(self, group_id: str, name: str) -> DeviceGroup | None:
96
+ """更新分组名称.
97
+
98
+ Args:
99
+ group_id: 分组 ID
100
+ name: 新名称
101
+
102
+ Returns:
103
+ DeviceGroup | None: 更新后的分组,如果不存在则返回 None
104
+ """
105
+ with self._lock:
106
+ groups, assignments = self._load_data()
107
+ for group in groups:
108
+ if group.id == group_id:
109
+ group.name = name
110
+ group.updated_at = datetime.now()
111
+ self._save_data(groups, assignments)
112
+ logger.info(f"Updated device group: {name} (id={group_id})")
113
+ return group
114
+ logger.warning(f"Device group not found for update: id={group_id}")
115
+ return None
116
+
117
+ def delete_group(self, group_id: str) -> bool:
118
+ """删除分组(设备移回默认分组).
119
+
120
+ Args:
121
+ group_id: 分组 ID
122
+
123
+ Returns:
124
+ bool: 删除成功返回 True,不存在或为默认分组返回 False
125
+ """
126
+ if group_id == DEFAULT_GROUP_ID:
127
+ logger.warning("Cannot delete default group")
128
+ return False
129
+
130
+ with self._lock:
131
+ groups, assignments = self._load_data()
132
+ original_len = len(groups)
133
+ groups = [g for g in groups if g.id != group_id]
134
+
135
+ if len(groups) < original_len:
136
+ # 将该分组的设备移到默认分组
137
+ moved_count = 0
138
+ for serial, gid in list(assignments.items()):
139
+ if gid == group_id:
140
+ assignments[serial] = DEFAULT_GROUP_ID
141
+ moved_count += 1
142
+
143
+ self._save_data(groups, assignments)
144
+ logger.info(
145
+ f"Deleted device group: id={group_id}, "
146
+ f"moved {moved_count} device(s) to default group"
147
+ )
148
+ return True
149
+
150
+ logger.warning(f"Device group not found for deletion: id={group_id}")
151
+ return False
152
+
153
+ def reorder_groups(self, group_ids: list[str]) -> bool:
154
+ """调整分组顺序.
155
+
156
+ Args:
157
+ group_ids: 按新顺序排列的分组 ID 列表
158
+
159
+ Returns:
160
+ bool: 调整成功返回 True
161
+ """
162
+ with self._lock:
163
+ groups, assignments = self._load_data()
164
+
165
+ # 创建 ID 到 group 的映射
166
+ group_map = {g.id: g for g in groups}
167
+
168
+ # 验证所有 ID 都存在
169
+ for gid in group_ids:
170
+ if gid not in group_map:
171
+ logger.warning(f"Group not found for reorder: id={gid}")
172
+ return False
173
+
174
+ # 更新 order 值
175
+ for order, gid in enumerate(group_ids):
176
+ group_map[gid].order = order
177
+ group_map[gid].updated_at = datetime.now()
178
+
179
+ self._save_data(groups, assignments)
180
+ logger.info(f"Reordered {len(group_ids)} groups")
181
+ return True
182
+
183
+ def assign_device(self, serial: str, group_id: str) -> bool:
184
+ """分配设备到分组.
185
+
186
+ Args:
187
+ serial: 设备 serial
188
+ group_id: 目标分组 ID
189
+
190
+ Returns:
191
+ bool: 分配成功返回 True
192
+ """
193
+ with self._lock:
194
+ groups, assignments = self._load_data()
195
+
196
+ # 验证分组存在
197
+ if not any(g.id == group_id for g in groups):
198
+ logger.warning(f"Group not found for assignment: id={group_id}")
199
+ return False
200
+
201
+ old_group = assignments.get(serial, DEFAULT_GROUP_ID)
202
+ assignments[serial] = group_id
203
+ self._save_data(groups, assignments)
204
+
205
+ if old_group != group_id:
206
+ logger.info(
207
+ f"Assigned device {serial} to group {group_id} (was: {old_group})"
208
+ )
209
+ return True
210
+
211
+ def get_device_group(self, serial: str) -> str:
212
+ """获取设备所属分组 ID.
213
+
214
+ Args:
215
+ serial: 设备 serial
216
+
217
+ Returns:
218
+ str: 分组 ID(未分配则返回默认分组 ID)
219
+ """
220
+ with self._lock:
221
+ _, assignments = self._load_data()
222
+ return assignments.get(serial, DEFAULT_GROUP_ID)
223
+
224
+ def get_devices_in_group(self, group_id: str) -> list[str]:
225
+ """获取分组内的设备 serial 列表.
226
+
227
+ Args:
228
+ group_id: 分组 ID
229
+
230
+ Returns:
231
+ list[str]: 设备 serial 列表
232
+ """
233
+ with self._lock:
234
+ _, assignments = self._load_data()
235
+
236
+ # 对于默认分组,包含未显式分配的设备
237
+ if group_id == DEFAULT_GROUP_ID:
238
+ # 注意:这里只返回显式分配到默认分组的设备
239
+ # 如果需要包含所有未分配设备,需要配合 DeviceManager 使用
240
+ return [
241
+ serial
242
+ for serial, gid in assignments.items()
243
+ if gid == DEFAULT_GROUP_ID
244
+ ]
245
+
246
+ return [serial for serial, gid in assignments.items() if gid == group_id]
247
+
248
+ def get_all_assignments(self) -> dict[str, str]:
249
+ """获取所有设备分配信息.
250
+
251
+ Returns:
252
+ dict[str, str]: serial -> group_id 的映射
253
+ """
254
+ with self._lock:
255
+ _, assignments = self._load_data()
256
+ return assignments.copy()
257
+
258
+ def _load_data(self) -> tuple[list[DeviceGroup], dict[str, str]]:
259
+ """从文件加载数据(带 mtime 缓存).
260
+
261
+ Returns:
262
+ tuple[list[DeviceGroup], dict[str, str]]: (分组列表, 设备分配映射)
263
+ """
264
+ # 检查文件是否存在
265
+ if not self._groups_path.exists():
266
+ # 创建默认分组
267
+ default_group = DeviceGroup.create_default_group()
268
+ groups = [default_group]
269
+ assignments: dict[str, str] = {}
270
+ self._save_data(groups, assignments)
271
+ return groups, assignments
272
+
273
+ # 检查缓存
274
+ current_mtime = self._groups_path.stat().st_mtime
275
+ if (
276
+ self._file_mtime == current_mtime
277
+ and self._groups_cache is not None
278
+ and self._assignments_cache is not None
279
+ ):
280
+ return self._groups_cache.copy(), self._assignments_cache.copy()
281
+
282
+ # 重新加载
283
+ try:
284
+ with open(self._groups_path, encoding="utf-8") as f:
285
+ data = json.load(f)
286
+
287
+ groups_data = data.get("groups", [])
288
+ groups = [DeviceGroup.from_dict(g) for g in groups_data]
289
+ assignments = data.get("device_assignments", {})
290
+
291
+ # 确保默认分组存在
292
+ if not any(g.id == DEFAULT_GROUP_ID for g in groups):
293
+ default_group = DeviceGroup.create_default_group()
294
+ groups.insert(0, default_group)
295
+ self._save_data(groups, assignments)
296
+
297
+ self._groups_cache = groups
298
+ self._assignments_cache = assignments
299
+ self._file_mtime = current_mtime
300
+ logger.debug(
301
+ f"Loaded {len(groups)} groups, {len(assignments)} device assignments"
302
+ )
303
+ return groups.copy(), assignments.copy()
304
+
305
+ except (json.JSONDecodeError, FileNotFoundError) as e:
306
+ logger.warning(f"Failed to load device groups: {e}")
307
+ # 返回默认分组
308
+ default_group = DeviceGroup.create_default_group()
309
+ return [default_group], {}
310
+
311
+ def _save_data(
312
+ self, groups: list[DeviceGroup], assignments: dict[str, str]
313
+ ) -> bool:
314
+ """原子写入文件.
315
+
316
+ Args:
317
+ groups: 分组列表
318
+ assignments: 设备分配映射
319
+
320
+ Returns:
321
+ bool: 保存成功返回 True
322
+ """
323
+ self._groups_path.parent.mkdir(parents=True, exist_ok=True)
324
+
325
+ data = {
326
+ "groups": [g.to_dict() for g in groups],
327
+ "device_assignments": assignments,
328
+ }
329
+
330
+ # 原子写入:临时文件 + rename
331
+ temp_path = self._groups_path.with_suffix(".tmp")
332
+ try:
333
+ with open(temp_path, "w", encoding="utf-8") as f:
334
+ json.dump(data, f, indent=2, ensure_ascii=False)
335
+ temp_path.replace(self._groups_path)
336
+
337
+ # 更新缓存
338
+ self._groups_cache = groups.copy()
339
+ self._assignments_cache = assignments.copy()
340
+ self._file_mtime = self._groups_path.stat().st_mtime
341
+ logger.debug(
342
+ f"Saved {len(groups)} groups, {len(assignments)} device assignments"
343
+ )
344
+ return True
345
+
346
+ except Exception as e:
347
+ logger.error(f"Failed to save device groups: {e}")
348
+ if temp_path.exists():
349
+ temp_path.unlink()
350
+ return False
351
+
352
+
353
+ # 单例实例
354
+ device_group_manager = DeviceGroupManager()
@@ -1,10 +1,18 @@
1
1
  """Data models for AutoGLM-GUI."""
2
2
 
3
+ from AutoGLM_GUI.models.device_group import (
4
+ DEFAULT_GROUP_ID,
5
+ DEFAULT_GROUP_NAME,
6
+ DeviceGroup,
7
+ )
3
8
  from AutoGLM_GUI.models.history import ConversationRecord, DeviceHistory
4
9
  from AutoGLM_GUI.models.scheduled_task import ScheduledTask
5
10
 
6
11
  __all__ = [
7
12
  "ConversationRecord",
8
13
  "DeviceHistory",
14
+ "DeviceGroup",
15
+ "DEFAULT_GROUP_ID",
16
+ "DEFAULT_GROUP_NAME",
9
17
  "ScheduledTask",
10
18
  ]
@@ -0,0 +1,63 @@
1
+ """Device group data models."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime
5
+ from uuid import uuid4
6
+
7
+ # Default group ID - this group cannot be deleted
8
+ DEFAULT_GROUP_ID = "default"
9
+ DEFAULT_GROUP_NAME = "默认分组"
10
+
11
+
12
+ @dataclass
13
+ class DeviceGroup:
14
+ """设备分组定义."""
15
+
16
+ id: str = field(default_factory=lambda: str(uuid4()))
17
+
18
+ # 基础信息
19
+ name: str = "" # 分组名称
20
+ order: int = 0 # 排序顺序(数字越小越靠前)
21
+
22
+ # 元数据
23
+ created_at: datetime = field(default_factory=datetime.now)
24
+ updated_at: datetime = field(default_factory=datetime.now)
25
+
26
+ def to_dict(self) -> dict:
27
+ """转换为可序列化的字典."""
28
+ return {
29
+ "id": self.id,
30
+ "name": self.name,
31
+ "order": self.order,
32
+ "created_at": self.created_at.isoformat(),
33
+ "updated_at": self.updated_at.isoformat(),
34
+ }
35
+
36
+ @classmethod
37
+ def from_dict(cls, data: dict) -> "DeviceGroup":
38
+ """从字典创建实例."""
39
+ return cls(
40
+ id=data.get("id", str(uuid4())),
41
+ name=data.get("name", ""),
42
+ order=data.get("order", 0),
43
+ created_at=datetime.fromisoformat(data["created_at"])
44
+ if data.get("created_at")
45
+ else datetime.now(),
46
+ updated_at=datetime.fromisoformat(data["updated_at"])
47
+ if data.get("updated_at")
48
+ else datetime.now(),
49
+ )
50
+
51
+ @classmethod
52
+ def create_default_group(cls) -> "DeviceGroup":
53
+ """创建默认分组."""
54
+ return cls(
55
+ id=DEFAULT_GROUP_ID,
56
+ name=DEFAULT_GROUP_NAME,
57
+ order=0,
58
+ )
59
+
60
+ @property
61
+ def is_default(self) -> bool:
62
+ """是否为默认分组."""
63
+ return self.id == DEFAULT_GROUP_ID
@@ -5,6 +5,25 @@ from datetime import datetime
5
5
  from uuid import uuid4
6
6
 
7
7
 
8
+ def _normalize_device_serialnos(serialnos: object) -> list[str]:
9
+ if isinstance(serialnos, str):
10
+ serialnos = [serialnos]
11
+ if not isinstance(serialnos, list):
12
+ return []
13
+
14
+ normalized: list[str] = []
15
+ seen: set[str] = set()
16
+ for raw in serialnos:
17
+ if not isinstance(raw, str):
18
+ continue
19
+ s = raw.strip()
20
+ if not s or s in seen:
21
+ continue
22
+ normalized.append(s)
23
+ seen.add(s)
24
+ return normalized
25
+
26
+
8
27
  @dataclass
9
28
  class ScheduledTask:
10
29
  """定时任务定义."""
@@ -14,7 +33,12 @@ class ScheduledTask:
14
33
  # 基础信息
15
34
  name: str = "" # 任务名称
16
35
  workflow_uuid: str = "" # 关联的 Workflow UUID
17
- device_serialno: str = "" # 绑定的设备 serialno
36
+ device_serialnos: list[str] = field(
37
+ default_factory=list
38
+ ) # 绑定的设备 serialno 列表
39
+ device_group_id: str | None = (
40
+ None # 绑定的设备分组 ID(与 device_serialnos 二选一)
41
+ )
18
42
 
19
43
  # 调度配置
20
44
  cron_expression: str = "" # Cron 表达式 (如 "0 8 * * *")
@@ -27,6 +51,10 @@ class ScheduledTask:
27
51
  # 最近执行信息(只记录最后一次)
28
52
  last_run_time: datetime | None = None
29
53
  last_run_success: bool | None = None
54
+ # success: 全部设备成功;partial: 部分成功;failure: 全部失败
55
+ last_run_status: str | None = None
56
+ last_run_success_count: int | None = None
57
+ last_run_total_count: int | None = None
30
58
  last_run_message: str | None = None
31
59
 
32
60
  def to_dict(self) -> dict:
@@ -35,7 +63,8 @@ class ScheduledTask:
35
63
  "id": self.id,
36
64
  "name": self.name,
37
65
  "workflow_uuid": self.workflow_uuid,
38
- "device_serialno": self.device_serialno,
66
+ "device_serialnos": self.device_serialnos,
67
+ "device_group_id": self.device_group_id,
39
68
  "cron_expression": self.cron_expression,
40
69
  "enabled": self.enabled,
41
70
  "created_at": self.created_at.isoformat(),
@@ -44,17 +73,28 @@ class ScheduledTask:
44
73
  if self.last_run_time
45
74
  else None,
46
75
  "last_run_success": self.last_run_success,
76
+ "last_run_status": self.last_run_status,
77
+ "last_run_success_count": self.last_run_success_count,
78
+ "last_run_total_count": self.last_run_total_count,
47
79
  "last_run_message": self.last_run_message,
48
80
  }
49
81
 
50
82
  @classmethod
51
83
  def from_dict(cls, data: dict) -> "ScheduledTask":
52
- """从字典创建实例."""
84
+ """从字典创建实例,向后兼容旧数据格式."""
85
+ # 处理设备序列号:支持旧格式的单字符串和新格式的列表
86
+ device_serialnos = _normalize_device_serialnos(data.get("device_serialnos", []))
87
+ if not device_serialnos:
88
+ # 向后兼容:尝试读取旧字段 device_serialno
89
+ old_device = _normalize_device_serialnos(data.get("device_serialno", ""))
90
+ device_serialnos = old_device
91
+
53
92
  return cls(
54
93
  id=data.get("id", str(uuid4())),
55
94
  name=data.get("name", ""),
56
95
  workflow_uuid=data.get("workflow_uuid", ""),
57
- device_serialno=data.get("device_serialno", ""),
96
+ device_serialnos=device_serialnos,
97
+ device_group_id=data.get("device_group_id"),
58
98
  cron_expression=data.get("cron_expression", ""),
59
99
  enabled=data.get("enabled", True),
60
100
  created_at=datetime.fromisoformat(data["created_at"])
@@ -67,5 +107,8 @@ class ScheduledTask:
67
107
  if data.get("last_run_time")
68
108
  else None,
69
109
  last_run_success=data.get("last_run_success"),
110
+ last_run_status=data.get("last_run_status"),
111
+ last_run_success_count=data.get("last_run_success_count"),
112
+ last_run_total_count=data.get("last_run_total_count"),
70
113
  last_run_message=data.get("last_run_message"),
71
114
  )