autoglm-gui 0.4.8__py3-none-any.whl → 0.4.11__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 +43 -0
- AutoGLM_GUI/__main__.py +63 -15
- AutoGLM_GUI/adb_plus/__init__.py +2 -0
- AutoGLM_GUI/adb_plus/keyboard_installer.py +380 -0
- AutoGLM_GUI/api/agents.py +123 -1
- AutoGLM_GUI/api/media.py +39 -44
- AutoGLM_GUI/config_manager.py +124 -0
- AutoGLM_GUI/logger.py +85 -0
- AutoGLM_GUI/platform_utils.py +43 -0
- AutoGLM_GUI/resources/apks/ADBKeyBoard.LICENSE.txt +339 -0
- AutoGLM_GUI/resources/apks/ADBKeyBoard.README.txt +1 -0
- AutoGLM_GUI/resources/apks/ADBKeyboard.apk +0 -0
- AutoGLM_GUI/schemas.py +17 -0
- AutoGLM_GUI/scrcpy_stream.py +58 -118
- AutoGLM_GUI/state.py +2 -1
- AutoGLM_GUI/static/assets/{about-BI6OV6gm.js → about-wSo3UgQ-.js} +1 -1
- AutoGLM_GUI/static/assets/chat-BcY2K0yj.js +25 -0
- AutoGLM_GUI/static/assets/{index-Do7ha9Kf.js → index-B5u1xtK1.js} +1 -1
- AutoGLM_GUI/static/assets/index-CHrYo3Qj.css +1 -0
- AutoGLM_GUI/static/assets/{index-Dn3vR6uV.js → index-D5BALRbT.js} +5 -5
- AutoGLM_GUI/static/index.html +2 -2
- {autoglm_gui-0.4.8.dist-info → autoglm_gui-0.4.11.dist-info}/METADATA +17 -2
- autoglm_gui-0.4.11.dist-info/RECORD +52 -0
- AutoGLM_GUI/static/assets/chat-C_2Cot0q.js +0 -25
- AutoGLM_GUI/static/assets/index-DCrxTz-A.css +0 -1
- autoglm_gui-0.4.8.dist-info/RECORD +0 -45
- {autoglm_gui-0.4.8.dist-info → autoglm_gui-0.4.11.dist-info}/WHEEL +0 -0
- {autoglm_gui-0.4.8.dist-info → autoglm_gui-0.4.11.dist-info}/entry_points.txt +0 -0
- {autoglm_gui-0.4.8.dist-info → autoglm_gui-0.4.11.dist-info}/licenses/LICENSE +0 -0
AutoGLM_GUI/api/agents.py
CHANGED
|
@@ -1,16 +1,25 @@
|
|
|
1
1
|
"""Agent lifecycle and chat routes."""
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
|
+
import os
|
|
4
5
|
|
|
5
6
|
from fastapi import APIRouter, HTTPException
|
|
6
7
|
from fastapi.responses import StreamingResponse
|
|
7
8
|
|
|
8
9
|
from AutoGLM_GUI.config import config
|
|
10
|
+
from AutoGLM_GUI.config_manager import (
|
|
11
|
+
delete_config_file,
|
|
12
|
+
get_config_path,
|
|
13
|
+
load_config_file,
|
|
14
|
+
save_config_file,
|
|
15
|
+
)
|
|
9
16
|
from AutoGLM_GUI.schemas import (
|
|
10
17
|
APIAgentConfig,
|
|
11
18
|
APIModelConfig,
|
|
12
19
|
ChatRequest,
|
|
13
20
|
ChatResponse,
|
|
21
|
+
ConfigResponse,
|
|
22
|
+
ConfigSaveRequest,
|
|
14
23
|
InitRequest,
|
|
15
24
|
ResetRequest,
|
|
16
25
|
StatusResponse,
|
|
@@ -31,6 +40,9 @@ router = APIRouter()
|
|
|
31
40
|
@router.post("/api/init")
|
|
32
41
|
def init_agent(request: InitRequest) -> dict:
|
|
33
42
|
"""初始化 PhoneAgent(多设备支持)。"""
|
|
43
|
+
from AutoGLM_GUI.adb_plus import ADBKeyboardInstaller
|
|
44
|
+
from AutoGLM_GUI.logger import logger
|
|
45
|
+
|
|
34
46
|
req_model_config = request.model or APIModelConfig()
|
|
35
47
|
req_agent_config = request.agent or APIAgentConfig()
|
|
36
48
|
|
|
@@ -41,13 +53,29 @@ def init_agent(request: InitRequest) -> dict:
|
|
|
41
53
|
)
|
|
42
54
|
config.refresh_from_env()
|
|
43
55
|
|
|
56
|
+
# 检查并自动安装 ADB Keyboard
|
|
57
|
+
logger.info(f"Checking ADB Keyboard for device {device_id}...")
|
|
58
|
+
installer = ADBKeyboardInstaller(device_id=device_id)
|
|
59
|
+
status = installer.get_status()
|
|
60
|
+
|
|
61
|
+
if not (status["installed"] and status["enabled"]):
|
|
62
|
+
logger.info(f"Setting up ADB Keyboard for device {device_id}...")
|
|
63
|
+
success, message = installer.auto_setup()
|
|
64
|
+
if success:
|
|
65
|
+
logger.info(f"✓ Device {device_id}: {message}")
|
|
66
|
+
else:
|
|
67
|
+
logger.warning(f"✗ Device {device_id}: {message}")
|
|
68
|
+
else:
|
|
69
|
+
logger.info(f"✓ Device {device_id}: ADB Keyboard ready")
|
|
70
|
+
|
|
44
71
|
base_url = req_model_config.base_url or config.base_url
|
|
45
72
|
api_key = req_model_config.api_key or config.api_key
|
|
46
73
|
model_name = req_model_config.model_name or config.model_name
|
|
47
74
|
|
|
48
75
|
if not base_url:
|
|
49
76
|
raise HTTPException(
|
|
50
|
-
status_code=400,
|
|
77
|
+
status_code=400,
|
|
78
|
+
detail="base_url is required. Please configure via Settings or start with --base-url",
|
|
51
79
|
)
|
|
52
80
|
|
|
53
81
|
model_config = ModelConfig(
|
|
@@ -228,3 +256,97 @@ def reset_agent(request: ResetRequest) -> dict:
|
|
|
228
256
|
"device_id": device_id,
|
|
229
257
|
"message": f"Agent reset for device {device_id}",
|
|
230
258
|
}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@router.get("/api/config", response_model=ConfigResponse)
|
|
262
|
+
def get_config_endpoint() -> ConfigResponse:
|
|
263
|
+
"""获取当前有效配置."""
|
|
264
|
+
from AutoGLM_GUI.config import config
|
|
265
|
+
|
|
266
|
+
# 加载配置文件
|
|
267
|
+
file_config = load_config_file()
|
|
268
|
+
|
|
269
|
+
# 读取当前实际运行的配置
|
|
270
|
+
current_base_url = os.getenv("AUTOGLM_BASE_URL", config.base_url)
|
|
271
|
+
current_model_name = os.getenv("AUTOGLM_MODEL_NAME", config.model_name)
|
|
272
|
+
current_api_key = os.getenv("AUTOGLM_API_KEY", config.api_key)
|
|
273
|
+
|
|
274
|
+
# 判断配置来源
|
|
275
|
+
# 如果环境变量中有 CLI 参数设置的值,优先级最高
|
|
276
|
+
env_config = {
|
|
277
|
+
"base_url": os.getenv("AUTOGLM_BASE_URL"),
|
|
278
|
+
"model_name": os.getenv("AUTOGLM_MODEL_NAME"),
|
|
279
|
+
"api_key": os.getenv("AUTOGLM_API_KEY"),
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
# 检查是否有 CLI 参数(环境变量值不同于默认值且文件配置中没有对应值)
|
|
283
|
+
has_cli_config = (
|
|
284
|
+
(env_config["base_url"] and env_config["base_url"] != "") and
|
|
285
|
+
(not file_config or file_config.get("base_url") != env_config["base_url"])
|
|
286
|
+
) or (
|
|
287
|
+
(env_config["model_name"] and env_config["model_name"] != "autoglm-phone-9b") and
|
|
288
|
+
(not file_config or file_config.get("model_name") != env_config["model_name"])
|
|
289
|
+
) or (
|
|
290
|
+
(env_config["api_key"] and env_config["api_key"] != "EMPTY") and
|
|
291
|
+
(not file_config or file_config.get("api_key") != env_config["api_key"])
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
if has_cli_config:
|
|
295
|
+
source = "CLI arguments"
|
|
296
|
+
elif file_config:
|
|
297
|
+
source = "config file"
|
|
298
|
+
else:
|
|
299
|
+
source = "default"
|
|
300
|
+
|
|
301
|
+
return ConfigResponse(
|
|
302
|
+
base_url=current_base_url,
|
|
303
|
+
model_name=current_model_name,
|
|
304
|
+
api_key=current_api_key if current_api_key != "EMPTY" else "",
|
|
305
|
+
source=source,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@router.post("/api/config")
|
|
310
|
+
def save_config_endpoint(request: ConfigSaveRequest) -> dict:
|
|
311
|
+
"""保存配置到文件."""
|
|
312
|
+
try:
|
|
313
|
+
config_data = {
|
|
314
|
+
"base_url": request.base_url,
|
|
315
|
+
"model_name": request.model_name,
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
# 只有提供了 api_key 才保存
|
|
319
|
+
if request.api_key:
|
|
320
|
+
config_data["api_key"] = request.api_key
|
|
321
|
+
|
|
322
|
+
success = save_config_file(config_data)
|
|
323
|
+
|
|
324
|
+
if success:
|
|
325
|
+
# 刷新全局配置
|
|
326
|
+
os.environ["AUTOGLM_BASE_URL"] = request.base_url
|
|
327
|
+
os.environ["AUTOGLM_MODEL_NAME"] = request.model_name
|
|
328
|
+
if request.api_key:
|
|
329
|
+
os.environ["AUTOGLM_API_KEY"] = request.api_key
|
|
330
|
+
config.refresh_from_env()
|
|
331
|
+
|
|
332
|
+
return {
|
|
333
|
+
"success": True,
|
|
334
|
+
"message": f"Configuration saved to {get_config_path()}",
|
|
335
|
+
}
|
|
336
|
+
else:
|
|
337
|
+
raise HTTPException(status_code=500, detail="Failed to save config")
|
|
338
|
+
except Exception as e:
|
|
339
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
@router.delete("/api/config")
|
|
343
|
+
def delete_config_endpoint() -> dict:
|
|
344
|
+
"""删除配置文件."""
|
|
345
|
+
try:
|
|
346
|
+
success = delete_config_file()
|
|
347
|
+
if success:
|
|
348
|
+
return {"success": True, "message": "Configuration deleted"}
|
|
349
|
+
else:
|
|
350
|
+
raise HTTPException(status_code=500, detail="Failed to delete config")
|
|
351
|
+
except Exception as e:
|
|
352
|
+
raise HTTPException(status_code=500, detail=str(e))
|
AutoGLM_GUI/api/media.py
CHANGED
|
@@ -7,6 +7,7 @@ from pathlib import Path
|
|
|
7
7
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
|
8
8
|
|
|
9
9
|
from AutoGLM_GUI.adb_plus import capture_screenshot
|
|
10
|
+
from AutoGLM_GUI.logger import logger
|
|
10
11
|
from AutoGLM_GUI.schemas import ScreenshotRequest, ScreenshotResponse
|
|
11
12
|
from AutoGLM_GUI.scrcpy_stream import ScrcpyStreamer
|
|
12
13
|
from AutoGLM_GUI.state import scrcpy_locks, scrcpy_streamers
|
|
@@ -24,10 +25,10 @@ async def reset_video_stream(device_id: str | None = None) -> dict:
|
|
|
24
25
|
if device_id in scrcpy_locks:
|
|
25
26
|
async with scrcpy_locks[device_id]:
|
|
26
27
|
if device_id in scrcpy_streamers:
|
|
27
|
-
|
|
28
|
+
logger.info(f"Stopping streamer for device {device_id}")
|
|
28
29
|
scrcpy_streamers[device_id].stop()
|
|
29
30
|
del scrcpy_streamers[device_id]
|
|
30
|
-
|
|
31
|
+
logger.info(f"Streamer reset for device {device_id}")
|
|
31
32
|
return {
|
|
32
33
|
"success": True,
|
|
33
34
|
"message": f"Video stream reset for device {device_id}",
|
|
@@ -45,7 +46,7 @@ async def reset_video_stream(device_id: str | None = None) -> dict:
|
|
|
45
46
|
if dev_id in scrcpy_streamers:
|
|
46
47
|
scrcpy_streamers[dev_id].stop()
|
|
47
48
|
del scrcpy_streamers[dev_id]
|
|
48
|
-
|
|
49
|
+
logger.info("All streamers reset")
|
|
49
50
|
return {"success": True, "message": "All video streams reset"}
|
|
50
51
|
|
|
51
52
|
|
|
@@ -84,43 +85,45 @@ async def video_stream_ws(
|
|
|
84
85
|
await websocket.send_json({"error": "device_id is required"})
|
|
85
86
|
return
|
|
86
87
|
|
|
87
|
-
|
|
88
|
+
logger.info(f"WebSocket connection for device {device_id}")
|
|
88
89
|
|
|
89
90
|
# Debug: Save stream to file for analysis (controlled by DEBUG_SAVE_VIDEO_STREAM env var)
|
|
90
91
|
debug_file = None
|
|
91
92
|
if DEBUG_SAVE_STREAM:
|
|
92
93
|
debug_dir = Path("debug_streams")
|
|
93
94
|
debug_dir.mkdir(exist_ok=True)
|
|
94
|
-
debug_file_path =
|
|
95
|
+
debug_file_path = (
|
|
96
|
+
debug_dir / f"{device_id}_{int(__import__('time').time())}.h264"
|
|
97
|
+
)
|
|
95
98
|
debug_file = open(debug_file_path, "wb")
|
|
96
|
-
|
|
99
|
+
logger.debug(f"DEBUG: Saving stream to {debug_file_path}")
|
|
97
100
|
|
|
98
101
|
if device_id not in scrcpy_locks:
|
|
99
102
|
scrcpy_locks[device_id] = asyncio.Lock()
|
|
100
103
|
|
|
101
104
|
async with scrcpy_locks[device_id]:
|
|
102
105
|
if device_id not in scrcpy_streamers:
|
|
103
|
-
|
|
106
|
+
logger.info(f"Creating streamer for device {device_id}")
|
|
104
107
|
scrcpy_streamers[device_id] = ScrcpyStreamer(
|
|
105
108
|
device_id=device_id, max_size=1280, bit_rate=4_000_000
|
|
106
109
|
)
|
|
107
110
|
|
|
108
111
|
try:
|
|
109
|
-
|
|
112
|
+
logger.info(f"Starting scrcpy server for device {device_id}")
|
|
110
113
|
await scrcpy_streamers[device_id].start()
|
|
111
|
-
|
|
114
|
+
logger.info(f"Scrcpy server started for device {device_id}")
|
|
112
115
|
|
|
113
116
|
# Read NAL units until we have SPS, PPS, and IDR
|
|
114
117
|
streamer = scrcpy_streamers[device_id]
|
|
115
118
|
|
|
116
|
-
|
|
119
|
+
logger.debug("Reading NAL units for initialization...")
|
|
117
120
|
for attempt in range(20): # Max 20 NAL units for initialization
|
|
118
121
|
try:
|
|
119
122
|
nal_unit = await streamer.read_nal_unit(auto_cache=True)
|
|
120
123
|
nal_type = nal_unit[4] & 0x1F if len(nal_unit) > 4 else -1
|
|
121
124
|
nal_type_names = {5: "IDR", 7: "SPS", 8: "PPS"}
|
|
122
|
-
|
|
123
|
-
f"
|
|
125
|
+
logger.debug(
|
|
126
|
+
f"Read NAL unit: type={nal_type_names.get(nal_type, nal_type)}, size={len(nal_unit)} bytes"
|
|
124
127
|
)
|
|
125
128
|
|
|
126
129
|
# Check if we have all required parameter sets
|
|
@@ -129,12 +132,12 @@ async def video_stream_ws(
|
|
|
129
132
|
and streamer.cached_pps
|
|
130
133
|
and streamer.cached_idr
|
|
131
134
|
):
|
|
132
|
-
|
|
133
|
-
f"
|
|
135
|
+
logger.debug(
|
|
136
|
+
f"✓ Initialization complete: SPS={len(streamer.cached_sps)}B, PPS={len(streamer.cached_pps)}B, IDR={len(streamer.cached_idr)}B"
|
|
134
137
|
)
|
|
135
138
|
break
|
|
136
139
|
except Exception as e:
|
|
137
|
-
|
|
140
|
+
logger.warning(f"Failed to read NAL unit: {e}")
|
|
138
141
|
await asyncio.sleep(0.5)
|
|
139
142
|
continue
|
|
140
143
|
|
|
@@ -147,8 +150,8 @@ async def video_stream_ws(
|
|
|
147
150
|
|
|
148
151
|
# Send initialization data as ONE message (SPS+PPS+IDR combined)
|
|
149
152
|
await websocket.send_bytes(init_data)
|
|
150
|
-
|
|
151
|
-
f"
|
|
153
|
+
logger.debug(
|
|
154
|
+
f"✓ Sent initialization data to first client: {len(init_data)} bytes total"
|
|
152
155
|
)
|
|
153
156
|
|
|
154
157
|
# Debug: Save to file
|
|
@@ -157,10 +160,7 @@ async def video_stream_ws(
|
|
|
157
160
|
debug_file.flush()
|
|
158
161
|
|
|
159
162
|
except Exception as e:
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
print(f"[video/stream] Failed to start streamer: {e}")
|
|
163
|
-
print(f"[video/stream] Traceback:\n{traceback.format_exc()}")
|
|
163
|
+
logger.exception(f"Failed to start streamer: {e}")
|
|
164
164
|
scrcpy_streamers[device_id].stop()
|
|
165
165
|
del scrcpy_streamers[device_id]
|
|
166
166
|
try:
|
|
@@ -169,7 +169,7 @@ async def video_stream_ws(
|
|
|
169
169
|
pass
|
|
170
170
|
return
|
|
171
171
|
else:
|
|
172
|
-
|
|
172
|
+
logger.info(f"Reusing streamer for device {device_id}")
|
|
173
173
|
|
|
174
174
|
streamer = scrcpy_streamers[device_id]
|
|
175
175
|
# CRITICAL: Send complete initialization data (SPS+PPS+IDR)
|
|
@@ -181,29 +181,29 @@ async def video_stream_ws(
|
|
|
181
181
|
init_data = streamer.get_initialization_data()
|
|
182
182
|
if init_data:
|
|
183
183
|
break
|
|
184
|
-
|
|
185
|
-
f"
|
|
184
|
+
logger.debug(
|
|
185
|
+
f"Waiting for initialization data (attempt {attempt + 1}/10)..."
|
|
186
186
|
)
|
|
187
187
|
await asyncio.sleep(0.5)
|
|
188
188
|
|
|
189
189
|
if init_data:
|
|
190
190
|
# Log what we're sending
|
|
191
|
-
|
|
192
|
-
f"
|
|
191
|
+
logger.debug(
|
|
192
|
+
f"✓ Sending cached initialization data for device {device_id}:"
|
|
193
193
|
)
|
|
194
|
-
|
|
194
|
+
logger.debug(
|
|
195
195
|
f" - SPS: {len(streamer.cached_sps) if streamer.cached_sps else 0}B"
|
|
196
196
|
)
|
|
197
|
-
|
|
197
|
+
logger.debug(
|
|
198
198
|
f" - PPS: {len(streamer.cached_pps) if streamer.cached_pps else 0}B"
|
|
199
199
|
)
|
|
200
|
-
|
|
200
|
+
logger.debug(
|
|
201
201
|
f" - IDR: {len(streamer.cached_idr) if streamer.cached_idr else 0}B"
|
|
202
202
|
)
|
|
203
|
-
|
|
203
|
+
logger.debug(f" - Total: {len(init_data)} bytes")
|
|
204
204
|
|
|
205
205
|
await websocket.send_bytes(init_data)
|
|
206
|
-
|
|
206
|
+
logger.debug("✓ Initialization data sent successfully")
|
|
207
207
|
|
|
208
208
|
# Debug: Save to file
|
|
209
209
|
if debug_file:
|
|
@@ -211,7 +211,7 @@ async def video_stream_ws(
|
|
|
211
211
|
debug_file.flush()
|
|
212
212
|
else:
|
|
213
213
|
error_msg = f"Initialization data not ready for device {device_id} after 5 seconds"
|
|
214
|
-
|
|
214
|
+
logger.error(f"ERROR: {error_msg}")
|
|
215
215
|
try:
|
|
216
216
|
await websocket.send_json({"error": error_msg})
|
|
217
217
|
except Exception:
|
|
@@ -237,11 +237,9 @@ async def video_stream_ws(
|
|
|
237
237
|
|
|
238
238
|
nal_count += 1
|
|
239
239
|
if nal_count % 100 == 0:
|
|
240
|
-
|
|
241
|
-
f"[video/stream] Device {device_id}: Sent {nal_count} NAL units"
|
|
242
|
-
)
|
|
240
|
+
logger.debug(f"Device {device_id}: Sent {nal_count} NAL units")
|
|
243
241
|
except ConnectionError as e:
|
|
244
|
-
|
|
242
|
+
logger.warning(f"Device {device_id}: Connection error: {e}")
|
|
245
243
|
stream_failed = True
|
|
246
244
|
try:
|
|
247
245
|
await websocket.send_json({"error": f"Stream error: {str(e)}"})
|
|
@@ -250,12 +248,9 @@ async def video_stream_ws(
|
|
|
250
248
|
break
|
|
251
249
|
|
|
252
250
|
except WebSocketDisconnect:
|
|
253
|
-
|
|
251
|
+
logger.info(f"Device {device_id}: Client disconnected")
|
|
254
252
|
except Exception as e:
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
print(f"[video/stream] Device {device_id}: Error: {e}")
|
|
258
|
-
print(f"[video/stream] Traceback:\n{traceback.format_exc()}")
|
|
253
|
+
logger.exception(f"Device {device_id}: Error: {e}")
|
|
259
254
|
stream_failed = True
|
|
260
255
|
try:
|
|
261
256
|
await websocket.send_json({"error": str(e)})
|
|
@@ -265,13 +260,13 @@ async def video_stream_ws(
|
|
|
265
260
|
if stream_failed:
|
|
266
261
|
async with scrcpy_locks[device_id]:
|
|
267
262
|
if device_id in scrcpy_streamers:
|
|
268
|
-
|
|
263
|
+
logger.info(f"Resetting streamer for device {device_id}")
|
|
269
264
|
scrcpy_streamers[device_id].stop()
|
|
270
265
|
del scrcpy_streamers[device_id]
|
|
271
266
|
|
|
272
267
|
# Debug: Close file
|
|
273
268
|
if debug_file:
|
|
274
269
|
debug_file.close()
|
|
275
|
-
|
|
270
|
+
logger.debug("DEBUG: Closed debug file")
|
|
276
271
|
|
|
277
|
-
|
|
272
|
+
logger.info(f"Device {device_id}: Stream ended")
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""配置文件管理模块."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from AutoGLM_GUI.logger import logger
|
|
7
|
+
|
|
8
|
+
# 默认配置
|
|
9
|
+
DEFAULT_CONFIG = {"base_url": "", "model_name": "autoglm-phone-9b", "api_key": "EMPTY"}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_config_path() -> Path:
|
|
13
|
+
"""获取配置文件路径.
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
Path: 配置文件路径 (~/.config/autoglm/config.json)
|
|
17
|
+
"""
|
|
18
|
+
config_dir = Path.home() / ".config" / "autoglm"
|
|
19
|
+
return config_dir / "config.json"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_config_file() -> dict | None:
|
|
23
|
+
"""从文件加载配置.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
dict | None: 配置字典,如果文件不存在或加载失败则返回 None
|
|
27
|
+
"""
|
|
28
|
+
config_path = get_config_path()
|
|
29
|
+
|
|
30
|
+
# 文件不存在(首次运行)
|
|
31
|
+
if not config_path.exists():
|
|
32
|
+
logger.debug(f"Config file not found at {config_path}")
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
with open(config_path, "r", encoding="utf-8") as f:
|
|
37
|
+
config = json.load(f)
|
|
38
|
+
logger.info(f"Loaded configuration from {config_path}")
|
|
39
|
+
return config
|
|
40
|
+
except json.JSONDecodeError as e:
|
|
41
|
+
logger.warning(f"Failed to parse config file {config_path}: {e}")
|
|
42
|
+
return None
|
|
43
|
+
except Exception as e:
|
|
44
|
+
logger.error(f"Failed to read config file {config_path}: {e}")
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def save_config_file(config: dict) -> bool:
|
|
49
|
+
"""保存配置到文件(原子写入).
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
config: 配置字典
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
bool: 成功返回 True,失败返回 False
|
|
56
|
+
"""
|
|
57
|
+
config_path = get_config_path()
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
# 确保目录存在
|
|
61
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
|
|
63
|
+
# 原子写入:先写入临时文件,然后重命名
|
|
64
|
+
temp_path = config_path.with_suffix(".tmp")
|
|
65
|
+
with open(temp_path, "w", encoding="utf-8") as f:
|
|
66
|
+
json.dump(config, f, indent=2, ensure_ascii=False)
|
|
67
|
+
|
|
68
|
+
# 重命名(原子操作)
|
|
69
|
+
temp_path.replace(config_path)
|
|
70
|
+
|
|
71
|
+
logger.info(f"Configuration saved to {config_path}")
|
|
72
|
+
return True
|
|
73
|
+
except Exception as e:
|
|
74
|
+
logger.error(f"Failed to save config file {config_path}: {e}")
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def delete_config_file() -> bool:
|
|
79
|
+
"""删除配置文件.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
bool: 成功返回 True,失败返回 False
|
|
83
|
+
"""
|
|
84
|
+
config_path = get_config_path()
|
|
85
|
+
|
|
86
|
+
if not config_path.exists():
|
|
87
|
+
logger.debug(f"Config file does not exist: {config_path}")
|
|
88
|
+
return True
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
config_path.unlink()
|
|
92
|
+
logger.info(f"Configuration deleted: {config_path}")
|
|
93
|
+
return True
|
|
94
|
+
except Exception as e:
|
|
95
|
+
logger.error(f"Failed to delete config file {config_path}: {e}")
|
|
96
|
+
return False
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def merge_configs(file_config: dict | None, cli_config: dict | None) -> dict:
|
|
100
|
+
"""合并配置(优先级:CLI > 文件 > 默认值).
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
file_config: 从文件加载的配置(可为 None)
|
|
104
|
+
cli_config: CLI 参数配置(可为 None)
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
dict: 合并后的配置字典
|
|
108
|
+
"""
|
|
109
|
+
# 从默认配置开始
|
|
110
|
+
merged = DEFAULT_CONFIG.copy()
|
|
111
|
+
|
|
112
|
+
# 应用文件配置(如果存在)
|
|
113
|
+
if file_config:
|
|
114
|
+
for key in DEFAULT_CONFIG.keys():
|
|
115
|
+
if key in file_config:
|
|
116
|
+
merged[key] = file_config[key]
|
|
117
|
+
|
|
118
|
+
# 应用 CLI 配置(最高优先级)
|
|
119
|
+
if cli_config:
|
|
120
|
+
for key in DEFAULT_CONFIG.keys():
|
|
121
|
+
if key in cli_config:
|
|
122
|
+
merged[key] = cli_config[key]
|
|
123
|
+
|
|
124
|
+
return merged
|
AutoGLM_GUI/logger.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Centralized logging configuration using loguru.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from loguru import logger
|
|
8
|
+
|
|
9
|
+
# Remove default handler
|
|
10
|
+
logger.remove()
|
|
11
|
+
|
|
12
|
+
# Default configuration - will be overridden by configure_logger()
|
|
13
|
+
_configured = False
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def configure_logger(
|
|
17
|
+
console_level: str = "INFO",
|
|
18
|
+
log_file: str | None = "logs/autoglm_{time:YYYY-MM-DD}.log",
|
|
19
|
+
log_level: str = "DEBUG",
|
|
20
|
+
rotation: str = "100 MB",
|
|
21
|
+
retention: str = "7 days",
|
|
22
|
+
compression: str = "zip",
|
|
23
|
+
) -> None:
|
|
24
|
+
"""
|
|
25
|
+
Configure the global logger with console and file handlers.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
console_level: Console output level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
|
29
|
+
log_file: Log file path (None to disable file logging)
|
|
30
|
+
log_level: File logging level
|
|
31
|
+
rotation: Log rotation policy (e.g., "100 MB", "1 day")
|
|
32
|
+
retention: Log retention policy (e.g., "7 days", "1 week")
|
|
33
|
+
compression: Compression format for rotated logs (e.g., "zip", "gz")
|
|
34
|
+
"""
|
|
35
|
+
global _configured
|
|
36
|
+
|
|
37
|
+
# Remove existing handlers if reconfiguring
|
|
38
|
+
if _configured:
|
|
39
|
+
logger.remove()
|
|
40
|
+
|
|
41
|
+
# Console handler with colors
|
|
42
|
+
logger.add(
|
|
43
|
+
sys.stderr,
|
|
44
|
+
format="<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>",
|
|
45
|
+
level=console_level,
|
|
46
|
+
colorize=True,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
# File handler
|
|
50
|
+
if log_file:
|
|
51
|
+
# Create logs directory if it doesn't exist
|
|
52
|
+
log_path = Path(log_file)
|
|
53
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
|
|
55
|
+
logger.add(
|
|
56
|
+
log_file,
|
|
57
|
+
rotation=rotation,
|
|
58
|
+
retention=retention,
|
|
59
|
+
compression=compression,
|
|
60
|
+
level=log_level,
|
|
61
|
+
format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {name}:{function}:{line} - {message}",
|
|
62
|
+
encoding="utf-8",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# Separate error log file
|
|
66
|
+
error_file = str(log_path.parent / f"errors_{log_path.name.split('_', 1)[1]}")
|
|
67
|
+
logger.add(
|
|
68
|
+
error_file,
|
|
69
|
+
rotation="50 MB",
|
|
70
|
+
retention="30 days",
|
|
71
|
+
compression=compression,
|
|
72
|
+
level="ERROR",
|
|
73
|
+
format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level: <8} | {name}:{function}:{line} - {message}",
|
|
74
|
+
backtrace=True,
|
|
75
|
+
diagnose=True,
|
|
76
|
+
encoding="utf-8",
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
_configured = True
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# Default initialization (can be reconfigured later)
|
|
83
|
+
configure_logger()
|
|
84
|
+
|
|
85
|
+
__all__ = ["logger", "configure_logger"]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Platform-aware subprocess helpers to avoid duplicated Windows branches."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import platform
|
|
5
|
+
import subprocess
|
|
6
|
+
from typing import Any, Sequence
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def is_windows() -> bool:
|
|
10
|
+
"""Return True if running on Windows."""
|
|
11
|
+
return platform.system() == "Windows"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
async def run_cmd_silently(cmd: Sequence[str]) -> subprocess.CompletedProcess:
|
|
15
|
+
"""Run a command, suppressing output but preserving it in the result; safe for async contexts on all platforms."""
|
|
16
|
+
if is_windows():
|
|
17
|
+
# Avoid blocking the event loop with a blocking subprocess call on Windows.
|
|
18
|
+
return await asyncio.to_thread(
|
|
19
|
+
subprocess.run, cmd, capture_output=True, text=True, check=False
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# Use PIPE on macOS/Linux to capture output
|
|
23
|
+
process = await asyncio.create_subprocess_exec(
|
|
24
|
+
*cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
|
25
|
+
)
|
|
26
|
+
stdout, stderr = await process.communicate()
|
|
27
|
+
# Decode bytes to string for API consistency across platforms
|
|
28
|
+
stdout_str = stdout.decode("utf-8") if stdout else ""
|
|
29
|
+
stderr_str = stderr.decode("utf-8") if stderr else ""
|
|
30
|
+
# Return CompletedProcess with stdout/stderr for API consistency across platforms
|
|
31
|
+
return_code = process.returncode if process.returncode is not None else -1
|
|
32
|
+
return subprocess.CompletedProcess(cmd, return_code, stdout_str, stderr_str)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def spawn_process(cmd: Sequence[str], *, capture_output: bool = False) -> Any:
|
|
36
|
+
"""Start a long-running process with optional stdio capture."""
|
|
37
|
+
stdout = subprocess.PIPE if capture_output else None
|
|
38
|
+
stderr = subprocess.PIPE if capture_output else None
|
|
39
|
+
|
|
40
|
+
if is_windows():
|
|
41
|
+
return subprocess.Popen(cmd, stdout=stdout, stderr=stderr)
|
|
42
|
+
|
|
43
|
+
return await asyncio.create_subprocess_exec(*cmd, stdout=stdout, stderr=stderr)
|