myagent-ai 1.18.1 → 1.18.3
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.
- package/core/vnc_manager.py +161 -51
- package/package.json +1 -1
- package/web/api_server.py +76 -11
- package/web/ui/chat/chat_main.js +21 -5
package/core/vnc_manager.py
CHANGED
|
@@ -79,6 +79,8 @@ class VNCManager:
|
|
|
79
79
|
|
|
80
80
|
# 状态标记
|
|
81
81
|
self._running = False
|
|
82
|
+
self._xvfb_attached = False # 是否附加到已有的 Xvfb 实例
|
|
83
|
+
self._xvfb_attached_pid: Optional[int] = None # 附加的 Xvfb PID
|
|
82
84
|
self._lock = asyncio.Lock()
|
|
83
85
|
|
|
84
86
|
# noVNC web 文件路径(用于 aiohttp 静态文件服务)
|
|
@@ -88,10 +90,27 @@ class VNCManager:
|
|
|
88
90
|
def is_running(self) -> bool:
|
|
89
91
|
"""VNC 服务是否正在运行"""
|
|
90
92
|
if not self._running:
|
|
91
|
-
|
|
93
|
+
# 即使内部标记为未运行,也检查系统上是否有 VNC 服务
|
|
94
|
+
# (myagent 重启后 _running 会重置为 False)
|
|
95
|
+
if self._detect_existing_services():
|
|
96
|
+
self._running = True
|
|
97
|
+
logger.info("检测到已有的 VNC 服务正在运行")
|
|
98
|
+
else:
|
|
99
|
+
return False
|
|
100
|
+
|
|
92
101
|
# 检查各进程是否存活
|
|
102
|
+
# Xvfb: 如果是附加的实例,通过 PID 检查
|
|
103
|
+
if self._xvfb_attached:
|
|
104
|
+
if self._xvfb_attached_pid and not self._is_pid_alive(self._xvfb_attached_pid):
|
|
105
|
+
logger.warning("附加的 Xvfb 进程已退出")
|
|
106
|
+
self._running = False
|
|
107
|
+
return False
|
|
108
|
+
elif self._xvfb_process and self._xvfb_process.poll() is not None:
|
|
109
|
+
logger.warning(f"Xvfb 进程已退出 (exit code: {self._xvfb_process.returncode})")
|
|
110
|
+
self._running = False
|
|
111
|
+
return False
|
|
112
|
+
|
|
93
113
|
for proc, name in [
|
|
94
|
-
(self._xvfb_process, "Xvfb"),
|
|
95
114
|
(self._x11vnc_process, "x11vnc"),
|
|
96
115
|
(self._websockify_process, "websockify"),
|
|
97
116
|
]:
|
|
@@ -101,6 +120,72 @@ class VNCManager:
|
|
|
101
120
|
return False
|
|
102
121
|
return True
|
|
103
122
|
|
|
123
|
+
def _is_pid_alive(self, pid: int) -> bool:
|
|
124
|
+
"""检查进程是否存活"""
|
|
125
|
+
try:
|
|
126
|
+
os.kill(pid, 0) # 发送信号 0,不实际杀死进程
|
|
127
|
+
return True
|
|
128
|
+
except (OSError, ProcessLookupError):
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
def _detect_existing_services(self) -> bool:
|
|
132
|
+
"""检测系统上是否已有 VNC 相关服务在运行(用于 myagent 重启后恢复状态)"""
|
|
133
|
+
display_num = self.display.replace(":", "")
|
|
134
|
+
socket_path = f"/tmp/.X11-unix/X{display_num}"
|
|
135
|
+
|
|
136
|
+
# 检查 Xvfb socket 和进程
|
|
137
|
+
xvfb_ok = False
|
|
138
|
+
if os.path.exists(socket_path):
|
|
139
|
+
try:
|
|
140
|
+
result = subprocess.run(
|
|
141
|
+
["pgrep", "-f", f"Xvfb {self.display}"],
|
|
142
|
+
capture_output=True, text=True, timeout=5,
|
|
143
|
+
)
|
|
144
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
145
|
+
xvfb_ok = True
|
|
146
|
+
except Exception:
|
|
147
|
+
pass
|
|
148
|
+
|
|
149
|
+
if not xvfb_ok:
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
# 检查 x11vnc 是否在监听端口
|
|
153
|
+
x11vnc_ok = self._is_port_listening(self.x11vnc_port)
|
|
154
|
+
if not x11vnc_ok:
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
# 检查 websockify 是否在监听端口
|
|
158
|
+
websockify_ok = self._is_port_listening(self.novnc_port)
|
|
159
|
+
|
|
160
|
+
if xvfb_ok and x11vnc_ok and websockify_ok:
|
|
161
|
+
# 恢复附加状态
|
|
162
|
+
self._xvfb_attached = True
|
|
163
|
+
try:
|
|
164
|
+
result = subprocess.run(
|
|
165
|
+
["pgrep", "-f", f"Xvfb {self.display}"],
|
|
166
|
+
capture_output=True, text=True, timeout=5,
|
|
167
|
+
)
|
|
168
|
+
if result.returncode == 0:
|
|
169
|
+
self._xvfb_attached_pid = int(result.stdout.strip().split()[0])
|
|
170
|
+
except Exception:
|
|
171
|
+
pass
|
|
172
|
+
return True
|
|
173
|
+
|
|
174
|
+
return False
|
|
175
|
+
|
|
176
|
+
@staticmethod
|
|
177
|
+
def _is_port_listening(port: int) -> bool:
|
|
178
|
+
"""检查端口是否在监听"""
|
|
179
|
+
try:
|
|
180
|
+
import socket
|
|
181
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
182
|
+
s.settimeout(0.5)
|
|
183
|
+
s.connect(("127.0.0.1", port))
|
|
184
|
+
s.close()
|
|
185
|
+
return True
|
|
186
|
+
except (OSError, ConnectionRefusedError):
|
|
187
|
+
return False
|
|
188
|
+
|
|
104
189
|
@property
|
|
105
190
|
def display_env(self) -> Dict[str, str]:
|
|
106
191
|
"""返回 DISPLAY 环境变量字典,供子进程使用"""
|
|
@@ -109,29 +194,39 @@ class VNCManager:
|
|
|
109
194
|
@property
|
|
110
195
|
def status(self) -> Dict[str, Any]:
|
|
111
196
|
"""获取当前状态信息"""
|
|
112
|
-
|
|
197
|
+
running = self.is_running
|
|
198
|
+
|
|
199
|
+
# Xvfb: 附加模式 vs 自启动模式
|
|
200
|
+
if self._xvfb_attached:
|
|
201
|
+
xvfb_alive = self._xvfb_attached_pid is not None and self._is_pid_alive(self._xvfb_attached_pid)
|
|
202
|
+
xvfb_pid = self._xvfb_attached_pid if xvfb_alive else None
|
|
203
|
+
else:
|
|
204
|
+
xvfb_alive = self._xvfb_process is not None and self._xvfb_process.poll() is None
|
|
205
|
+
xvfb_pid = self._xvfb_process.pid if xvfb_alive else None
|
|
206
|
+
|
|
113
207
|
vnc_alive = self._x11vnc_process is not None and self._x11vnc_process.poll() is None
|
|
114
208
|
ws_alive = self._websockify_process is not None and self._websockify_process.poll() is None
|
|
115
209
|
|
|
116
210
|
return {
|
|
117
|
-
"running":
|
|
211
|
+
"running": running,
|
|
118
212
|
"display": self.display,
|
|
119
213
|
"resolution": self.resolution,
|
|
120
214
|
"xvfb": {
|
|
121
|
-
"alive": xvfb_alive,
|
|
122
|
-
"pid":
|
|
215
|
+
"alive": xvfb_alive or running, # 如果整体在运行,Xvfb 肯定是活的
|
|
216
|
+
"pid": xvfb_pid,
|
|
217
|
+
"attached": self._xvfb_attached,
|
|
123
218
|
},
|
|
124
219
|
"x11vnc": {
|
|
125
|
-
"alive": vnc_alive,
|
|
220
|
+
"alive": vnc_alive or running,
|
|
126
221
|
"port": self.x11vnc_port,
|
|
127
222
|
"pid": self._x11vnc_process.pid if vnc_alive else None,
|
|
128
223
|
},
|
|
129
224
|
"websockify": {
|
|
130
|
-
"alive": ws_alive,
|
|
225
|
+
"alive": ws_alive or running,
|
|
131
226
|
"port": self.novnc_port,
|
|
132
227
|
"pid": self._websockify_process.pid if ws_alive else None,
|
|
133
228
|
},
|
|
134
|
-
"novnc_url": f"/vnc/vnc.html?autoconnect=true&resize=scale" if
|
|
229
|
+
"novnc_url": f"/vnc/vnc.html?autoconnect=true&resize=scale" if running else None,
|
|
135
230
|
}
|
|
136
231
|
|
|
137
232
|
def get_novnc_url(self, base_url: str = "") -> Optional[str]:
|
|
@@ -280,7 +375,7 @@ class VNCManager:
|
|
|
280
375
|
"""启动 VNC 远程桌面服务。
|
|
281
376
|
|
|
282
377
|
启动顺序:
|
|
283
|
-
1. Xvfb (虚拟显示)
|
|
378
|
+
1. Xvfb (虚拟显示) — 如果已在运行则附加
|
|
284
379
|
2. x11vnc (VNC 服务器)
|
|
285
380
|
3. websockify (WebSocket 代理)
|
|
286
381
|
|
|
@@ -288,7 +383,9 @@ class VNCManager:
|
|
|
288
383
|
{"success": bool, "message": str, "status": dict}
|
|
289
384
|
"""
|
|
290
385
|
async with self._lock:
|
|
291
|
-
|
|
386
|
+
# 检查是否已在运行(包括检测系统上已有的服务)
|
|
387
|
+
if self._running or self._detect_existing_services():
|
|
388
|
+
self._running = True
|
|
292
389
|
return {
|
|
293
390
|
"success": True,
|
|
294
391
|
"message": "VNC 服务已在运行中",
|
|
@@ -300,31 +397,7 @@ class VNCManager:
|
|
|
300
397
|
if not ok:
|
|
301
398
|
return {"success": False, "message": f"依赖检查失败: {dep_msg}"}
|
|
302
399
|
|
|
303
|
-
# Step 2:
|
|
304
|
-
display_num = self.display.replace(":", "")
|
|
305
|
-
lock_file = f"/tmp/.X{display_num}-lock"
|
|
306
|
-
if os.path.exists(lock_file):
|
|
307
|
-
# 检查是否有 Xvfb 进程在运行
|
|
308
|
-
try:
|
|
309
|
-
result = subprocess.run(
|
|
310
|
-
["pgrep", "-f", f"Xvfb {self.display}"],
|
|
311
|
-
capture_output=True, text=True, timeout=5,
|
|
312
|
-
)
|
|
313
|
-
if result.returncode == 0 and result.stdout.strip():
|
|
314
|
-
logger.info(f"Xvfb {self.display} 已在运行,跳过启动")
|
|
315
|
-
else:
|
|
316
|
-
# 锁文件存在但进程不在,清理锁文件
|
|
317
|
-
try:
|
|
318
|
-
os.remove(lock_file)
|
|
319
|
-
socket_file = f"/tmp/.X11-unix/X{display_num}"
|
|
320
|
-
if os.path.exists(socket_file):
|
|
321
|
-
os.remove(socket_file)
|
|
322
|
-
except OSError:
|
|
323
|
-
pass
|
|
324
|
-
except Exception:
|
|
325
|
-
pass
|
|
326
|
-
|
|
327
|
-
# Step 3: 启动 Xvfb
|
|
400
|
+
# Step 2: 启动或附加 Xvfb
|
|
328
401
|
xvfb_ok = await self._start_xvfb()
|
|
329
402
|
if not xvfb_ok:
|
|
330
403
|
await self.stop()
|
|
@@ -333,7 +406,7 @@ class VNCManager:
|
|
|
333
406
|
# 等待 Xvfb 就绪
|
|
334
407
|
await asyncio.sleep(1)
|
|
335
408
|
|
|
336
|
-
# Step
|
|
409
|
+
# Step 3: 启动 x11vnc
|
|
337
410
|
vnc_ok = await self._start_x11vnc()
|
|
338
411
|
if not vnc_ok:
|
|
339
412
|
await self.stop()
|
|
@@ -342,13 +415,13 @@ class VNCManager:
|
|
|
342
415
|
# 等待 x11vnc 就绪
|
|
343
416
|
await asyncio.sleep(1)
|
|
344
417
|
|
|
345
|
-
# Step
|
|
418
|
+
# Step 4: 启动 websockify
|
|
346
419
|
ws_ok = await self._start_websockify()
|
|
347
420
|
if not ws_ok:
|
|
348
421
|
await self.stop()
|
|
349
422
|
return {"success": False, "message": "websockify 启动失败", "status": self.status}
|
|
350
423
|
|
|
351
|
-
# Step
|
|
424
|
+
# Step 5: 查找 noVNC web 文件
|
|
352
425
|
self._novnc_web_dir = self._find_novnc_dir()
|
|
353
426
|
if self._novnc_web_dir:
|
|
354
427
|
logger.info(f"noVNC web 文件: {self._novnc_web_dir}")
|
|
@@ -383,20 +456,27 @@ class VNCManager:
|
|
|
383
456
|
errors.extend(await self._kill_process(self._x11vnc_process, "x11vnc"))
|
|
384
457
|
self._x11vnc_process = None
|
|
385
458
|
|
|
459
|
+
# Xvfb: 只有自己启动的才杀,附加的不杀
|
|
386
460
|
if self._xvfb_process:
|
|
387
461
|
errors.extend(await self._kill_process(self._xvfb_process, "Xvfb"))
|
|
388
462
|
self._xvfb_process = None
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
463
|
+
elif self._xvfb_attached:
|
|
464
|
+
logger.info(f"Xvfb {self.display} 是附加到已有实例,不停止 (PID={self._xvfb_attached_pid})")
|
|
465
|
+
self._xvfb_attached = False
|
|
466
|
+
self._xvfb_attached_pid = None
|
|
467
|
+
|
|
468
|
+
# 清理锁文件和 socket (只清理自己启动的)
|
|
469
|
+
if not self._xvfb_attached:
|
|
470
|
+
display_num = self.display.replace(":", "")
|
|
471
|
+
for f in [f"/tmp/.X{display_num}-lock", f"/tmp/.X11-unix/X{display_num}"]:
|
|
472
|
+
try:
|
|
473
|
+
if os.path.exists(f):
|
|
474
|
+
os.remove(f)
|
|
475
|
+
except OSError:
|
|
476
|
+
pass
|
|
398
477
|
|
|
399
478
|
self._running = False
|
|
479
|
+
self._xvfb_attached = False
|
|
400
480
|
|
|
401
481
|
if errors:
|
|
402
482
|
return {"success": True, "message": f"VNC 已停止 (部分警告: {'; '.join(errors)})"}
|
|
@@ -489,8 +569,40 @@ class VNCManager:
|
|
|
489
569
|
# ── 内部方法 ──────────────────────────────────────────
|
|
490
570
|
|
|
491
571
|
async def _start_xvfb(self) -> bool:
|
|
492
|
-
"""
|
|
572
|
+
"""启动或附加到 Xvfb 虚拟显示服务器"""
|
|
493
573
|
try:
|
|
574
|
+
display_num = self.display.replace(":", "")
|
|
575
|
+
socket_path = f"/tmp/.X11-unix/X{display_num}"
|
|
576
|
+
|
|
577
|
+
# 检查 Xvfb 是否已在运行
|
|
578
|
+
if os.path.exists(socket_path):
|
|
579
|
+
try:
|
|
580
|
+
result = subprocess.run(
|
|
581
|
+
["pgrep", "-f", f"Xvfb {self.display}"],
|
|
582
|
+
capture_output=True, text=True, timeout=5,
|
|
583
|
+
)
|
|
584
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
585
|
+
# Xvfb 已在运行,附加到它
|
|
586
|
+
pid = int(result.stdout.strip().split()[0])
|
|
587
|
+
self._xvfb_attached = True
|
|
588
|
+
self._xvfb_attached_pid = pid
|
|
589
|
+
logger.info(f"Xvfb {self.display} 已在运行 (PID={pid}),附加到现有实例")
|
|
590
|
+
return True
|
|
591
|
+
else:
|
|
592
|
+
# 锁文件/socket 存在但进程不在,清理
|
|
593
|
+
logger.info(f"Xvfb {self.display} socket 存在但进程不在,清理残留文件")
|
|
594
|
+
lock_file = f"/tmp/.X{display_num}-lock"
|
|
595
|
+
try:
|
|
596
|
+
os.remove(lock_file)
|
|
597
|
+
except OSError:
|
|
598
|
+
pass
|
|
599
|
+
try:
|
|
600
|
+
os.remove(socket_path)
|
|
601
|
+
except OSError:
|
|
602
|
+
pass
|
|
603
|
+
except Exception as e:
|
|
604
|
+
logger.warning(f"检查 Xvfb 状态异常: {e}")
|
|
605
|
+
|
|
494
606
|
cmd = [
|
|
495
607
|
"Xvfb",
|
|
496
608
|
self.display,
|
|
@@ -529,8 +641,6 @@ class VNCManager:
|
|
|
529
641
|
return False
|
|
530
642
|
|
|
531
643
|
# 验证 X socket 是否已创建
|
|
532
|
-
display_num = self.display.replace(":", "")
|
|
533
|
-
socket_path = f"/tmp/.X11-unix/X{display_num}"
|
|
534
644
|
for _ in range(10):
|
|
535
645
|
if os.path.exists(socket_path):
|
|
536
646
|
break
|
package/package.json
CHANGED
package/web/api_server.py
CHANGED
|
@@ -4061,13 +4061,14 @@ window.toggleFullscreen = function() {{
|
|
|
4061
4061
|
return chain
|
|
4062
4062
|
|
|
4063
4063
|
def _reorder_model_chain_for_images(self, model_chain: list[dict], has_images: bool) -> list[dict]:
|
|
4064
|
-
"""[v1.16.14→18] 当消息包含图片时,将支持 vision 的模型优先排列
|
|
4064
|
+
"""[v1.16.14→18.2] 当消息包含图片时,将支持 vision 的模型优先排列
|
|
4065
4065
|
|
|
4066
4066
|
优先从 model_chain 自带的 input_modes 读取(v1.16.18 改进),
|
|
4067
4067
|
其次从 models_library 二次查找(兼容旧逻辑)。
|
|
4068
4068
|
|
|
4069
4069
|
检查每个模型的 input_modes 字段,如果包含 "image",则优先使用。
|
|
4070
|
-
|
|
4070
|
+
如果 chain 中没有 vision 模型,自动从全局 models_library 查找 vision
|
|
4071
|
+
兜底模型追加到 chain 末尾(v1.18.2 改进)。
|
|
4071
4072
|
"""
|
|
4072
4073
|
if not has_images or not model_chain:
|
|
4073
4074
|
return model_chain
|
|
@@ -4075,9 +4076,11 @@ window.toggleFullscreen = function() {{
|
|
|
4075
4076
|
# 从 model_chain 自带的 input_modes 或 models_library 获取
|
|
4076
4077
|
vision_models = []
|
|
4077
4078
|
text_only_models = []
|
|
4079
|
+
chain_ids = set()
|
|
4078
4080
|
for mc in model_chain:
|
|
4079
4081
|
mc_id = mc.get("id", "")
|
|
4080
4082
|
model_name = mc.get("name", mc.get("model", "?"))
|
|
4083
|
+
chain_ids.add(mc_id)
|
|
4081
4084
|
|
|
4082
4085
|
# [v1.16.18] 优先使用 chain 自带的 input_modes
|
|
4083
4086
|
input_modes = mc.get("input_modes", None)
|
|
@@ -4091,8 +4094,6 @@ window.toggleFullscreen = function() {{
|
|
|
4091
4094
|
input_modes = me.input_modes or ["text"]
|
|
4092
4095
|
break
|
|
4093
4096
|
|
|
4094
|
-
logger.debug(f"模型 {model_name} (id={mc_id}) input_modes={input_modes}")
|
|
4095
|
-
# [v1.17.1] 改为 info 级别,方便排查
|
|
4096
4097
|
logger.info(f"[reorder] 模型 {model_name} (id={mc_id}) input_modes={input_modes}")
|
|
4097
4098
|
if "image" in input_modes:
|
|
4098
4099
|
vision_models.append(mc)
|
|
@@ -4102,13 +4103,43 @@ window.toggleFullscreen = function() {{
|
|
|
4102
4103
|
if vision_models:
|
|
4103
4104
|
logger.info(f"消息含图片,优先使用 vision 模型: {[m.get('name', m.get('model')) for m in vision_models]}")
|
|
4104
4105
|
return vision_models + text_only_models
|
|
4105
|
-
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
|
|
4109
|
-
|
|
4110
|
-
|
|
4111
|
-
|
|
4106
|
+
|
|
4107
|
+
# [v1.18.2] chain 中没有 vision 模型 → 从全局 models_library 自动找 vision 兜底
|
|
4108
|
+
llm_defaults = self.core.config.llm
|
|
4109
|
+
auto_vision = None
|
|
4110
|
+
for me in self.core.config.models_library:
|
|
4111
|
+
if me.id in chain_ids:
|
|
4112
|
+
continue # 跳过已在 chain 中的
|
|
4113
|
+
modes = me.input_modes or ["text"]
|
|
4114
|
+
if "image" in modes and me.enabled:
|
|
4115
|
+
auto_vision = me
|
|
4116
|
+
break
|
|
4117
|
+
|
|
4118
|
+
if auto_vision:
|
|
4119
|
+
vision_mc = {
|
|
4120
|
+
"id": auto_vision.id,
|
|
4121
|
+
"name": auto_vision.name,
|
|
4122
|
+
"provider": auto_vision.provider or llm_defaults.provider,
|
|
4123
|
+
"api_type": getattr(auto_vision, 'api_type', '') or llm_defaults.api_type,
|
|
4124
|
+
"model": auto_vision.model or auto_vision.id,
|
|
4125
|
+
"base_url": auto_vision.base_url or llm_defaults.base_url,
|
|
4126
|
+
"api_key": auto_vision.api_key or llm_defaults.api_key,
|
|
4127
|
+
"temperature": auto_vision.temperature if auto_vision.temperature is not None else llm_defaults.temperature,
|
|
4128
|
+
"max_tokens": auto_vision.max_tokens if auto_vision.max_tokens else llm_defaults.max_tokens,
|
|
4129
|
+
"context_window": getattr(auto_vision, 'context_window', None) or llm_defaults.context_window,
|
|
4130
|
+
"input_modes": list(auto_vision.input_modes or ["text"]),
|
|
4131
|
+
"is_backup": True,
|
|
4132
|
+
"_auto_vision": True, # 标记:自动追加的 vision 兜底
|
|
4133
|
+
}
|
|
4134
|
+
logger.warning(f"消息含图片,agent 模型链无 vision 模型,自动追加全局 vision 兜底: {auto_vision.name} (id={auto_vision.id})")
|
|
4135
|
+
return [vision_mc] + text_only_models
|
|
4136
|
+
|
|
4137
|
+
# 既没有 chain 内 vision,也没有全局兜底 → 保持原序,让 main_agent 降级纯文本
|
|
4138
|
+
chain_info = []
|
|
4139
|
+
for mc in model_chain:
|
|
4140
|
+
chain_info.append(f"{mc.get('name', mc.get('model', '?'))}(id={mc.get('id','')}, modes={mc.get('input_modes','?')})")
|
|
4141
|
+
logger.warning(f"消息含图片,但无可用 vision 模型,链详情: {chain_info},将降级纯文本")
|
|
4142
|
+
return model_chain
|
|
4112
4143
|
|
|
4113
4144
|
async def _try_model_chain(self, model_chain: list[dict], message: str, session_id: str,
|
|
4114
4145
|
agent_path: str = None, agent_system_prompt: str = None,
|
|
@@ -4135,6 +4166,7 @@ window.toggleFullscreen = function() {{
|
|
|
4135
4166
|
|
|
4136
4167
|
for i, mc in enumerate(model_chain):
|
|
4137
4168
|
is_backup = mc.get("is_backup", False)
|
|
4169
|
+
is_auto_vision = mc.get("_auto_vision", False)
|
|
4138
4170
|
model_label = f"{'备用' if is_backup else '主'}模型 {mc.get('name', mc.get('model', '?'))}"
|
|
4139
4171
|
logger.info(f"尝试 {model_label} ({i+1}/{len(model_chain)}): provider={mc.get('provider')}, model={mc.get('model')}")
|
|
4140
4172
|
|
|
@@ -4208,6 +4240,10 @@ window.toggleFullscreen = function() {{
|
|
|
4208
4240
|
used_model_name = model_label
|
|
4209
4241
|
if is_backup:
|
|
4210
4242
|
logger.warning(f"🔄 主模型失败,成功切换到 {model_label}")
|
|
4243
|
+
# [v1.18.2] 自动 vision 兜底成功的提醒
|
|
4244
|
+
if is_auto_vision:
|
|
4245
|
+
_hint = f"💡 当前绑定的模型不支持图片,已自动切换到 {mc.get('name', mc.get('model', '?'))} 处理。\n\n"
|
|
4246
|
+
return _hint + response
|
|
4211
4247
|
return response
|
|
4212
4248
|
|
|
4213
4249
|
last_error = response
|
|
@@ -4265,6 +4301,8 @@ window.toggleFullscreen = function() {{
|
|
|
4265
4301
|
"""_try_model_chain_stream 的实际执行体(已在 _model_chain_lock 保护下)"""
|
|
4266
4302
|
llm = self.core.llm
|
|
4267
4303
|
full_text = ""
|
|
4304
|
+
_auto_vision_switched = False # [v1.18.2] 标记是否发生了 vision 自动切换
|
|
4305
|
+
_auto_vision_model_name = ""
|
|
4268
4306
|
|
|
4269
4307
|
for i, mc in enumerate(model_chain):
|
|
4270
4308
|
orig = {
|
|
@@ -4288,6 +4326,11 @@ window.toggleFullscreen = function() {{
|
|
|
4288
4326
|
agent.context_builder.context_window = mc["context_window"]
|
|
4289
4327
|
llm._client = None
|
|
4290
4328
|
|
|
4329
|
+
# [v1.18.2] 检测是否是自动追加的 vision 兜底模型
|
|
4330
|
+
is_auto_vision = mc.get("_auto_vision", False)
|
|
4331
|
+
if is_auto_vision:
|
|
4332
|
+
_auto_vision_model_name = mc.get("name", mc.get("model", "?"))
|
|
4333
|
+
|
|
4291
4334
|
# Pass agent context through AgentContext instead of instance attrs
|
|
4292
4335
|
result = await self._stream_process_message(
|
|
4293
4336
|
message, session_id, stream_response,
|
|
@@ -4296,6 +4339,11 @@ window.toggleFullscreen = function() {{
|
|
|
4296
4339
|
user_images=user_images, user_files=user_files,
|
|
4297
4340
|
)
|
|
4298
4341
|
if result and not result.startswith("⚠️") and not result.startswith("❌"):
|
|
4342
|
+
# [v1.18.2] 如果是通过自动 vision 兜底成功的,在响应前追加提醒
|
|
4343
|
+
if is_auto_vision and user_images:
|
|
4344
|
+
_hint = f"💡 当前绑定的模型不支持图片,已自动切换到 {_auto_vision_model_name} 处理。\n\n"
|
|
4345
|
+
logger.info(f"自动 vision 兜底成功: {_auto_vision_model_name}")
|
|
4346
|
+
return _hint + result
|
|
4299
4347
|
return result
|
|
4300
4348
|
# 如果返回了错误消息,保存它以便最后返回
|
|
4301
4349
|
if result:
|
|
@@ -4314,6 +4362,23 @@ window.toggleFullscreen = function() {{
|
|
|
4314
4362
|
if hasattr(agent, 'context_builder') and agent.context_builder:
|
|
4315
4363
|
agent.context_builder.context_window = orig["context_window"]
|
|
4316
4364
|
|
|
4365
|
+
# [v1.18.2] 所有模型都失败,且包含图片,给出明确的配置提示
|
|
4366
|
+
if user_images and full_text and "不支持图片" in full_text:
|
|
4367
|
+
# 查找可用的 vision 模型名称
|
|
4368
|
+
vision_names = []
|
|
4369
|
+
for me in self.core.config.models_library:
|
|
4370
|
+
modes = me.input_modes or ["text"]
|
|
4371
|
+
if "image" in modes and me.enabled:
|
|
4372
|
+
vision_names.append(me.name or me.id)
|
|
4373
|
+
if vision_names:
|
|
4374
|
+
return (f"⚠️ 当前绑定的模型不支持图片识别,自动切换也未找到可用的图片模型。\n\n"
|
|
4375
|
+
f"📋 模型库中支持图片的模型: {', '.join(vision_names[:5])}\n\n"
|
|
4376
|
+
f"请在 agent 设置中将其中一个绑定为模型或备用模型,以便识别图片。")
|
|
4377
|
+
else:
|
|
4378
|
+
return (f"⚠️ 当前绑定的模型不支持图片识别,且模型库中没有任何支持图片的模型。\n\n"
|
|
4379
|
+
f"请在 models_library 中添加一个支持 vision 的模型(input_modes 包含 \"image\"),"
|
|
4380
|
+
f"然后绑定到当前 agent。")
|
|
4381
|
+
|
|
4317
4382
|
return full_text
|
|
4318
4383
|
|
|
4319
4384
|
async def _stream_text_chunked(self, text: str, write_sse, chunk_size: int = 4, delay: float = 0.015):
|
package/web/ui/chat/chat_main.js
CHANGED
|
@@ -942,8 +942,18 @@ function updateVNCButton() {
|
|
|
942
942
|
}
|
|
943
943
|
|
|
944
944
|
async function toggleVNC() {
|
|
945
|
+
// 先刷新一次状态(避免因轮询间隔导致状态过期)
|
|
946
|
+
try {
|
|
947
|
+
var latest = await api('/api/vnc/status');
|
|
948
|
+
if (latest && latest.running) {
|
|
949
|
+
vncStatus = latest;
|
|
950
|
+
updateVNCButton();
|
|
951
|
+
openVNCWindow();
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
} catch (e) {}
|
|
955
|
+
|
|
945
956
|
if (vncStatus.running) {
|
|
946
|
-
// VNC 正在运行,打开远程桌面窗口
|
|
947
957
|
openVNCWindow();
|
|
948
958
|
return;
|
|
949
959
|
}
|
|
@@ -957,12 +967,18 @@ async function toggleVNC() {
|
|
|
957
967
|
try {
|
|
958
968
|
var result = await api('/api/vnc/start', { method: 'POST' });
|
|
959
969
|
if (result.success) {
|
|
960
|
-
toast('远程桌面已启动', 'success');
|
|
961
970
|
await refreshVNCStatus();
|
|
962
|
-
//
|
|
963
|
-
|
|
971
|
+
// 如果是"已在运行"(附加到已有实例),立刻打开窗口
|
|
972
|
+
if (result.message && result.message.indexOf('已在运行') > -1) {
|
|
973
|
+
toast('远程桌面已连接', 'success');
|
|
964
974
|
openVNCWindow();
|
|
965
|
-
}
|
|
975
|
+
} else {
|
|
976
|
+
toast('远程桌面已启动', 'success');
|
|
977
|
+
// 新启动的需要等服务完全就绪
|
|
978
|
+
setTimeout(function() {
|
|
979
|
+
openVNCWindow();
|
|
980
|
+
}, 2000);
|
|
981
|
+
}
|
|
966
982
|
} else {
|
|
967
983
|
toast('启动失败: ' + (result.message || '未知错误'), 'error');
|
|
968
984
|
}
|