myagent-ai 1.18.6 → 1.18.8

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.
@@ -100,8 +100,10 @@ DEPENDENCIES: List[DepInfo] = [
100
100
  DepInfo("edge_tts", "edge-tts", "6.1.0", "tts", "all"),
101
101
 
102
102
  # ── 语音识别 (STT) ──
103
+ DepInfo("funasr", "funasr", "1.1.0", "stt", "all",
104
+ note="[v1.18.7] SenseVoice 中文语音识别(推荐,需 torch+torchaudio)"),
103
105
  DepInfo("faster_whisper", "faster-whisper", "1.0.0", "stt", "all",
104
- note="本地语音识别引擎 (需要 C++ 编译)"),
106
+ note="Whisper 本地语音识别引擎 (需要 C++ 编译)"),
105
107
  DepInfo("speech_recognition", "SpeechRecognition", "3.10.0", "stt", "all",
106
108
  note="在线语音识别 (Google API,纯 Python 无需编译,Termux 兼容)"),
107
109
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "myagent-ai",
3
- "version": "1.18.6",
3
+ "version": "1.18.8",
4
4
  "description": "本地桌面端执行型AI助手 - Open Interpreter 风格 | Local Desktop Execution-Oriented AI Assistant",
5
5
  "main": "main.py",
6
6
  "bin": {
package/skills/base.py CHANGED
@@ -94,10 +94,14 @@ class Skill(ABC):
94
94
  pass
95
95
 
96
96
  def validate_params(self, params: Dict[str, Any]) -> tuple[bool, str]:
97
- """校验参数是否合法"""
97
+ """校验参数是否合法。有默认值的必需参数在缺失时自动填充。"""
98
98
  for p in self.parameters:
99
- if p.required and p.name not in params:
100
- return False, f"缺少必需参数: {p.name}"
99
+ if p.name not in params:
100
+ # [v1.18.7] 有默认值的必需参数:自动填充而非报错
101
+ if p.default is not None:
102
+ params[p.name] = p.default
103
+ elif p.required:
104
+ return False, f"缺少必需参数: {p.name}"
101
105
  if p.name in params and p.enum and params[p.name] not in p.enum:
102
106
  return False, f"参数 {p.name} 值无效,可选: {p.enum}"
103
107
  return True, ""
@@ -8,6 +8,7 @@ from __future__ import annotations
8
8
 
9
9
  import json
10
10
  import os
11
+ import time
11
12
  from pathlib import Path
12
13
  from typing import Any, Dict, List, Optional
13
14
 
@@ -29,18 +30,19 @@ class XLSXCreateSkill(Skill):
29
30
  """
30
31
  name = "xlsx_create"
31
32
  description = (
32
- "生成 Excel (XLSX) 电子表格。支持多工作表、表头、数据行、公式、"
33
+ "生成 Excel (XLSX) 电子表格文件。支持多工作表、表头、数据行、公式、"
33
34
  "冻结窗格、自动筛选、列宽设置。"
34
- "sheets JSON 对象,key 为工作表名。"
35
+ "参数 sheets 必须是 JSON 字符串,格式为 {\"工作表名\": {\"headers\":[...], \"rows\":[[...]]}}。"
36
+ "参数 output_path 指定输出文件路径,默认为工作目录下。"
35
37
  )
36
38
  category = "doc"
37
39
  dangerous = True
38
40
  parameters = [
39
41
  SkillParameter("sheets", "string",
40
- "工作表数据 JSON 对象。key=工作表名, value={headers, rows, col_widths?, formulas?}。"
41
- "示例: {\"Sheet1\":{\"headers\":[\"Name\",\"Score\"],\"rows\":[[\"Alice\",95]]}}",
42
+ "工作表数据,JSON 字符串。key=工作表名, value包含 headers(列名数组) rows(数据行二维数组)。"
43
+ "示例: '{\"Sheet1\":{\"headers\":[\"姓名\",\"分数\"],\"rows\":[[\"Alice\",95]]}}'",
42
44
  required=True),
43
- SkillParameter("output_path", "string", "输出 XLSX 文件路径", required=True),
45
+ SkillParameter("output_path", "string", "输出文件路径(如 /tmp/report.xlsx)", required=False, default=""),
44
46
  SkillParameter("title", "string", "文档标题", required=False, default=""),
45
47
  ]
46
48
 
@@ -62,6 +64,15 @@ class XLSXCreateSkill(Skill):
62
64
 
63
65
  try:
64
66
  out = Path(output_path).expanduser().resolve()
67
+ if not output_path.strip():
68
+ # [v1.18.7] 默认输出路径:工作目录/data/workspace下
69
+ from core.context_manager import get_active_context
70
+ try:
71
+ ctx = get_active_context()
72
+ work_dir = Path(ctx.work_dir) if ctx and ctx.work_dir else Path.cwd()
73
+ except Exception:
74
+ work_dir = Path.cwd()
75
+ out = work_dir / f"report_{int(time.time())}.xlsx"
65
76
  out.parent.mkdir(parents=True, exist_ok=True)
66
77
 
67
78
  wb = openpyxl.Workbook()
package/web/api_server.py CHANGED
@@ -373,6 +373,7 @@ class ApiServer:
373
373
  r.add_get("/api/workdir", self.handle_get_workdir)
374
374
  # ── Task Plan ──
375
375
  r.add_get("/api/task-plan", self.handle_get_task_plan)
376
+ r.add_get("/api/task-plan/all", self.handle_get_all_task_plans)
376
377
  r.add_put("/api/task-plan", self.handle_update_task_plan)
377
378
  r.add_post("/api/task-plan", self.handle_add_task_item)
378
379
  r.add_delete("/api/task-plan/{idx:int}", self.handle_delete_task_item)
@@ -1544,13 +1545,15 @@ window.toggleFullscreen = function() {{
1544
1545
  return web.json_response({"error": str(e)}, status=500)
1545
1546
 
1546
1547
  async def handle_voice_stt(self, request):
1547
- """POST /api/voice-stt - 轻量级本地语音转文字
1548
+ """POST /api/voice-stt - 本地语音转文字
1548
1549
 
1549
1550
  接受音频文件(WAV/WEBM/OGG),使用本地 STT 引擎转录。
1550
1551
  支持的引擎(按优先级):
1551
- 1. faster-whisper(推荐,需安装:pip install faster-whisper
1552
- 2. vosk(备选,需安装:pip install vosk
1553
- 如果都未安装,返回错误提示。
1552
+ 1. [v1.18.7] SenseVoice(推荐,中文识别最佳,需:pip install funasr torch torchaudio
1553
+ 2. faster-whisper(备选,需安装:pip install faster-whisper
1554
+ 3. vosk(备选,需安装:pip install vosk)
1555
+ 4. LLM API Whisper 兼容端点
1556
+ 5. SpeechRecognition(Google,需外网)
1554
1557
  """
1555
1558
  try:
1556
1559
  reader = await request.multipart()
@@ -1583,6 +1586,59 @@ window.toggleFullscreen = function() {{
1583
1586
 
1584
1587
  import io
1585
1588
 
1589
+ # ── [v1.18.7] 首选: SenseVoice(阿里达摩院,中文识别极佳) ──
1590
+ try:
1591
+ sv_model = getattr(self, '_sensevoice_model', None)
1592
+ if sv_model is None:
1593
+ os.environ.setdefault("HF_HUB_DISABLE_TELEMETRY", "1")
1594
+ os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1")
1595
+ from funasr import AutoModel
1596
+ model_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'models', 'sensevoice')
1597
+ sv_model = AutoModel(model="iic/SenseVoiceSmall", model_dir=model_dir,
1598
+ device="cpu", disable_pbar=True, disable_update=True)
1599
+ self._sensevoice_model = sv_model
1600
+ logger.info("SenseVoice 模型已加载 (iic/SenseVoiceSmall, CPU)")
1601
+
1602
+ # SenseVoice 接受 16kHz WAV
1603
+ wav_path = f"/tmp/myagent_stt_{id(audio_data) % 100000}.wav"
1604
+ wav_buf = io.BytesIO()
1605
+ try:
1606
+ from pydub import AudioSegment
1607
+ audio_buf = io.BytesIO(audio_data)
1608
+ seg = AudioSegment.from_file(audio_buf, format=audio_format or "webm")
1609
+ seg = seg.set_channels(1).set_frame_rate(16000).set_sample_width(2)
1610
+ seg.export(wav_buf, format="wav")
1611
+ except Exception:
1612
+ wav_buf = io.BytesIO(audio_data)
1613
+ wav_buf.seek(0)
1614
+ with open(wav_path, 'wb') as f:
1615
+ f.write(wav_buf.read())
1616
+
1617
+ # SenseVoice 推理
1618
+ res = sv_model.generate(input=wav_path, cache={},
1619
+ language="auto", # 自动检测语言
1620
+ use_itn=True, # 逆文本标准化(数字/日期等)
1621
+ batch_size_s=300)
1622
+ if res and len(res) > 0 and len(res[0]) > 0:
1623
+ text = res[0][0]["text"] if isinstance(res[0][0], dict) else str(res[0][0])
1624
+ # SenseVoice 可能输出带 <|zh|><|en|><|EMO|> 等特殊 token,清理掉
1625
+ import re
1626
+ text = re.sub(r'<\|[^|]+\|>', '', text).strip()
1627
+ if text:
1628
+ try:
1629
+ os.remove(wav_path)
1630
+ except Exception:
1631
+ pass
1632
+ return web.json_response({"text": text, "engine": "sensevoice"})
1633
+ try:
1634
+ os.remove(wav_path)
1635
+ except Exception:
1636
+ pass
1637
+ except ImportError:
1638
+ logger.debug("SenseVoice (funasr) 未安装,跳过。安装: pip install funasr torch torchaudio")
1639
+ except Exception as e:
1640
+ logger.warning(f"SenseVoice 转录失败: {e}")
1641
+
1586
1642
  # ── 尝试 faster-whisper ──
1587
1643
  try:
1588
1644
  whisper_model = self._whisper_model
@@ -1814,10 +1870,11 @@ window.toggleFullscreen = function() {{
1814
1870
  # ── 没有可用的 STT 引擎 ──
1815
1871
  return web.json_response({
1816
1872
  "error": "未检测到可用的 STT 引擎。请尝试以下方案:\n"
1817
- " 1. 配置支持 Whisper LLM API(自动使用,推荐)\n"
1818
- " 2. pip install faster-whisper (离线本地,需 C++ 编译环境)\n"
1819
- " 3. pip install vosk (离线本地,需下载模型)\n"
1820
- " 4. pip install SpeechRecognition (需外网,国内不可用)",
1873
+ " 1. pip install funasr torch torchaudio (SenseVoice,中文最佳,推荐)\n"
1874
+ " 2. 配置支持 Whisper LLM API(自动使用,无需安装)\n"
1875
+ " 3. pip install faster-whisper (离线本地,需 C++ 编译环境)\n"
1876
+ " 4. pip install vosk (离线本地,需下载模型)\n"
1877
+ " 5. pip install SpeechRecognition (需外网,国内不可用)",
1821
1878
  "available": False,
1822
1879
  }, status=503)
1823
1880
 
@@ -2103,6 +2160,36 @@ window.toggleFullscreen = function() {{
2103
2160
  self._task_list_store[store_key] = tasks
2104
2161
  return web.json_response({"ok": True, "tasks": tasks, "session": session_id})
2105
2162
 
2163
+ async def handle_get_all_task_plans(self, request):
2164
+ """GET /api/task-plan/all - 获取所有活跃的 task plan(用于后台管理任务列表)。
2165
+
2166
+ 返回所有 _task_list_store 中的非空任务列表,包含 session_id/agent_path 信息。
2167
+ """
2168
+ all_plans = []
2169
+ for key, tasks in self._task_list_store.items():
2170
+ if not tasks:
2171
+ continue
2172
+ # 统计状态
2173
+ status_counts = {"pending": 0, "running": 0, "done": 0}
2174
+ for t in tasks:
2175
+ s = t.get("status", "pending")
2176
+ status_counts[s] = status_counts.get(s, 0) + 1
2177
+ # 判断 key 类型
2178
+ is_session = key.startswith(("web_", "cli_", "group_", "tg_", "discord_", "feishu_"))
2179
+ all_plans.append({
2180
+ "key": key,
2181
+ "type": "session" if is_session else "agent",
2182
+ "total": len(tasks),
2183
+ "pending": status_counts["pending"],
2184
+ "running": status_counts["running"],
2185
+ "done": status_counts["done"],
2186
+ "tasks": tasks,
2187
+ "label": key.split("_web_")[0][:30] if is_session else key,
2188
+ })
2189
+ # 按 total 降序
2190
+ all_plans.sort(key=lambda x: x["total"], reverse=True)
2191
+ return web.json_response({"plans": all_plans, "total_keys": len(self._task_list_store)})
2192
+
2106
2193
  async def handle_shutdown(self, request):
2107
2194
  self.core._running = False
2108
2195
  asyncio.create_task(self.core.shutdown())
@@ -2830,13 +2917,31 @@ window.toggleFullscreen = function() {{
2830
2917
  async def handle_get_executor(self, request):
2831
2918
  info = self.core.executor.get_execution_info() if self.core.executor else {}
2832
2919
  cfg = self.core.config.executor if self.core.config else None
2920
+ # [v1.18.7] 附加执行锁状态和沙盒说明
2921
+ lock = self._execution_lock
2833
2922
  return web.json_response({
2834
2923
  **info,
2835
2924
  "timeout": cfg.timeout if cfg else 300,
2836
2925
  "auto_fix": cfg.auto_fix if cfg else True,
2837
2926
  "max_output_length": cfg.max_output_length if cfg else 50000,
2927
+ # 执行锁信息
2928
+ "lock": {
2929
+ "locked": lock["locked"],
2930
+ "locked_by": lock["locked_by"],
2931
+ "locked_at": lock["locked_at"],
2932
+ },
2933
+ # 沙盒模式说明
2934
+ "sandbox_desc": self._sandbox_description(info.get("mode"), info.get("sandbox_type"), info.get("docker_available")),
2838
2935
  })
2839
2936
 
2937
+ def _sandbox_description(self, mode, sandbox_type, docker_available):
2938
+ """生成沙盒模式说明文本"""
2939
+ if mode != "sandbox":
2940
+ return "本机模式: 代码直接在宿主机运行,拥有完整系统权限。多个 Agent 共享全局执行锁,同一时间只有一个 Agent 可以执行代码。"
2941
+ if docker_available:
2942
+ return "Docker 沙盒: 代码在 Docker 容器中运行,通过网络隔离保证安全性。容器使用指定镜像,执行完毕后自动销毁。不走全局锁,支持多 Agent 并发。"
2943
+ return "轻量级进程沙盒: Docker 不可用时的降级方案。代码在临时工作目录中通过子进程执行,提供目录隔离。不走全局锁,支持多 Agent 并发。安全性低于 Docker 沙盒。"
2944
+
2840
2945
  async def handle_update_executor(self, request):
2841
2946
  data = await request.json()
2842
2947
  cfg_path = self.core.config_mgr._config_file
@@ -3852,35 +3957,65 @@ window.toggleFullscreen = function() {{
3852
3957
  lines = int(request.query.get("lines", "200"))
3853
3958
  level = request.query.get("level", "").upper()
3854
3959
  logs = []
3855
- for lf in sorted(log_dir.glob("myagent*.log"), reverse=True):
3960
+ # [v1.18.7] 遍历所有 .log 文件(不仅限于 myagent*.log),按修改时间倒序
3961
+ try:
3962
+ all_log_files = sorted(
3963
+ [f for f in log_dir.glob("*.log") if f.is_file()],
3964
+ key=lambda f: f.stat().st_mtime,
3965
+ reverse=True,
3966
+ )
3967
+ except Exception:
3968
+ all_log_files = []
3969
+ for lf in all_log_files:
3856
3970
  try:
3857
3971
  text = lf.read_text(encoding="utf-8", errors="ignore")
3858
3972
  for line in text.strip().split("\n")[-lines:]:
3859
- if level and level not in line: continue
3973
+ if level and level not in line:
3974
+ continue
3860
3975
  logs.append(line)
3861
- if len(logs) >= lines: break
3862
- except: pass
3976
+ if len(logs) >= lines:
3977
+ break
3978
+ except Exception:
3979
+ pass
3863
3980
  return web.json_response(logs[-lines:])
3864
3981
 
3865
3982
  async def handle_log_stream(self, request):
3866
3983
  resp = web.StreamResponse()
3867
3984
  resp.content_type = "text/event-stream"
3868
3985
  resp.headers["Cache-Control"] = "no-cache"
3986
+ resp.headers["Connection"] = "keep-alive"
3869
3987
  await resp.prepare(request)
3870
- log_file = self.core.config_mgr.logs_dir / "myagent.log"
3988
+ log_dir = self.core.config_mgr.logs_dir
3989
+ # [v1.18.7] 自动发现最新的日志文件而非硬编码 myagent.log
3871
3990
  last_pos = 0
3991
+ last_file = None
3872
3992
  try:
3873
3993
  while True:
3874
3994
  try:
3875
- if log_file.exists():
3876
- size = log_file.stat().st_size
3995
+ # 每 5 秒重新扫描最新日志文件(应对日志轮转)
3996
+ current_file = None
3997
+ if log_dir.exists():
3998
+ candidates = [f for f in log_dir.glob("*.log") if f.is_file()]
3999
+ if candidates:
4000
+ current_file = max(candidates, key=lambda f: f.stat().st_mtime)
4001
+ if current_file:
4002
+ # 日志文件切换时重置位置
4003
+ if last_file and current_file.resolve() != last_file.resolve():
4004
+ last_pos = 0
4005
+ last_file = current_file
4006
+ size = current_file.stat().st_size
3877
4007
  if size > last_pos:
3878
- with open(log_file, "r", encoding="utf-8", errors="ignore") as f:
3879
- f.seek(last_pos); new_data = f.read(); last_pos = size
3880
- if new_data: await resp.write(f"data: {json.dumps(new_data.strip())}\n\n")
3881
- except: pass
4008
+ with open(current_file, "r", encoding="utf-8", errors="ignore") as f:
4009
+ f.seek(last_pos)
4010
+ new_data = f.read()
4011
+ last_pos = size
4012
+ if new_data:
4013
+ await resp.write(f"data: {json.dumps(new_data.strip())}\n\n")
4014
+ except Exception:
4015
+ pass
3882
4016
  await asyncio.sleep(0.5)
3883
- except asyncio.CancelledError: pass
4017
+ except asyncio.CancelledError:
4018
+ pass
3884
4019
  return resp
3885
4020
 
3886
4021
  # ── 配置管理 (热重载 / 导入 / 导出) ──
@@ -6127,11 +6262,14 @@ window.toggleFullscreen = function() {{
6127
6262
  break
6128
6263
  image_data.extend(chunk)
6129
6264
 
6130
- # 解析裁剪参数
6131
- crop_x = int(request.query.get("crop_x", 0))
6132
- crop_y = int(request.query.get("crop_y", 0))
6133
- crop_w = int(request.query.get("crop_w", 0))
6134
- crop_h = int(request.query.get("crop_h", 0))
6265
+ # 解析裁剪参数([v1.18.7] 防止 NaN 传入导致 ValueError)
6266
+ try:
6267
+ crop_x = int(float(request.query.get("crop_x", 0)))
6268
+ crop_y = int(float(request.query.get("crop_y", 0)))
6269
+ crop_w = int(float(request.query.get("crop_w", 0)))
6270
+ crop_h = int(float(request.query.get("crop_h", 0)))
6271
+ except (ValueError, TypeError):
6272
+ crop_x = crop_y = crop_w = crop_h = 0
6135
6273
  out_size = min(int(request.query.get("size", 128)), 512)
6136
6274
 
6137
6275
  # 使用 Pillow 处理图片
@@ -1871,14 +1871,24 @@ function renderSessions(filter = '') {
1871
1871
  s.id !== '__new__' && (!fl || s.name.toLowerCase().includes(fl) || s.id.toLowerCase().includes(fl))
1872
1872
  );
1873
1873
 
1874
+ const agent = findAgentByPath(state.activeAgent);
1874
1875
  list.innerHTML = filtered.map(s => {
1875
1876
  const previewText = s.preview
1876
1877
  ? escapeHtml(s.preview.length > 40 ? s.preview.slice(0, 40) + '...' : s.preview)
1877
1878
  : (s.messages > 0 ? s.messages + ' 条消息' : '暂无消息');
1879
+ // [v1.18.7] 用 agent 头像替代固定 💬 图标,头像在前、会话名在后
1880
+ var _avatarContent = '';
1881
+ if (s.id === '__new__') {
1882
+ _avatarContent = '✨';
1883
+ } else if (agent && agent.avatar_image) {
1884
+ _avatarContent = '<img src="' + escapeHtml(agent.avatar_image) + '" style="width:100%;height:100%;object-fit:cover;border-radius:8px" onerror="this.outerHTML=\'' + escapeHtml(agent.avatar_emoji || '🤖') + '\'">';
1885
+ } else {
1886
+ _avatarContent = agent ? (agent.avatar_emoji || '🤖') : '💬';
1887
+ }
1878
1888
  return `
1879
1889
  <div class="session-item ${s.id === state.activeSessionId ? 'active' : ''}"
1880
1890
  onclick="selectSession('${escapeHtml(s.id)}')" title="${escapeHtml(s.id)}">
1881
- <div class="session-icon">${s.id === '__new__' ? '✨' : '💬'}</div>
1891
+ <div class="session-icon">${_avatarContent}</div>
1882
1892
  <div class="session-info">
1883
1893
  <div class="session-name">${escapeHtml(s.name)}</div>
1884
1894
  <div class="session-preview">${previewText}</div>
package/web/ui/index.html CHANGED
@@ -635,7 +635,8 @@ async function doCreateAgent(){
635
635
  name,description:$('caDesc').value,avatar_emoji:$('caEmoji').value,
636
636
  avatar_color:$('caColorText').value,execution_mode:$('caExecMode').value,
637
637
  model_id:$('caModelId').value,system_prompt:$('caPrompt').value,
638
- work_dir:$('caWorkDir').value,department:$('caDept').value
638
+ work_dir:$('caWorkDir').value,department:$('caDept').value,
639
+ avatar_image:$('caAvatarImage')?.value||''
639
640
  })});
640
641
  if(r.error){showToast(r.error,'danger');return}
641
642
  closeModal();showToast('Agent 创建成功','success');renderAgents();
@@ -764,7 +765,8 @@ async function doSaveAgent(path){
764
765
  description:$('eaDesc').value,avatar_emoji:$('eaEmoji').value,avatar_color:$('eaColorText').value,
765
766
  model_id:$('eaModelId').value,
766
767
  backup_model_ids:Array.from($('eaBackupModels').selectedOptions).map(o=>o.value),
767
- work_dir:$('eaWorkDir').value,system_prompt:$('eaPrompt').value
768
+ work_dir:$('eaWorkDir').value,system_prompt:$('eaPrompt').value,
769
+ avatar_image:$('eaAvatarImage')?.value||''
768
770
  })});
769
771
  if(r.error){showToast(r.error,'danger');return}
770
772
  showToast('已保存','success');renderAgents();
@@ -1625,29 +1627,63 @@ async function deleteModel(id,name){
1625
1627
 
1626
1628
  // ========== Executor ==========
1627
1629
  async function renderExecutor(){
1628
- const e=await api('/api/executor');const isSandbox=e.mode==='sandbox';const dockerOk=e.docker_available;
1630
+ const e=await api('/api/executor');if(!e)return;
1631
+ const isSandbox=e.mode==='sandbox';const dockerOk=e.docker_available;
1632
+ const lock=e.lock||{};
1633
+ const sandboxType=e.sandbox_type||'';
1634
+ const sandboxDesc=e.sandbox_desc||'';
1629
1635
  let html=`<div class="card"><h3>执行模式</h3>
1630
- <div style="display:flex;gap:12px;margin-bottom:16px">
1631
- <label style="display:flex;align-items:center;gap:6px;cursor:pointer;padding:16px 24px;border-radius:var(--radius);border:2px solid ${!isSandbox?'var(--primary)':'var(--border)'};background:${!isSandbox?'#6366f122':'transparent'};flex:1">
1636
+ <div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap">
1637
+ <label style="display:flex;align-items:center;gap:6px;cursor:pointer;padding:16px 24px;border-radius:var(--radius);border:2px solid ${!isSandbox?'var(--primary)':'var(--border)'};background:${!isSandbox?'#6366f122':'transparent'};flex:1;min-width:200px">
1632
1638
  <input type="radio" name="execMode" value="local" ${!isSandbox?'checked':''} onchange="switchMode('local')">
1633
1639
  <div><strong>🖥️ 本机执行</strong><br><span style="font-size:12px;color:var(--text2)">直接在本机运行代码,功能完整,速度最快</span></div></label>
1634
- <label style="display:flex;align-items:center;gap:6px;cursor:pointer;padding:16px 24px;border-radius:var(--radius);border:2px solid ${isSandbox?'var(--primary)':'var(--border)'};background:${isSandbox?'#6366f122':'transparent'};flex:1">
1640
+ <label style="display:flex;align-items:center;gap:6px;cursor:pointer;padding:16px 24px;border-radius:var(--radius);border:2px solid ${isSandbox?'var(--primary)':'var(--border)'};background:${isSandbox?'#6366f122':'transparent'};flex:1;min-width:200px">
1635
1641
  <input type="radio" name="execMode" value="sandbox" ${isSandbox?'checked':''} onchange="switchMode('sandbox')" ${!dockerOk?'disabled':''}>
1636
- <div><strong>📦 沙盒执行 (Docker)</strong><br><span style="font-size:12px;color:var(--text2)">在隔离容器中运行,更安全${!dockerOk?' (Docker 不可用)':''}</span></div></label></div>
1637
- <div style="font-size:13px;color:var(--text2)">当前模式: <span class="badge ${isSandbox?'badge-yellow':'badge-green'}">${isSandbox?'沙盒 (Docker)':'本机'}</span> Docker 状态: <span class="badge ${dockerOk?'badge-green':'badge-red'}">${dockerOk?'可用':'不可用'}</span> 累计执行: <span class="tag">${e.execution_count||0} 次</span></div></div>`;
1642
+ <div><strong>📦 沙盒执行</strong><br><span style="font-size:12px;color:var(--text2)">${dockerOk?'Docker 隔离容器':'轻量级进程沙盒'}${!dockerOk?' (Docker 不可用)':''}</span></div></label></div>
1643
+ <div style="font-size:13px;color:var(--text2);display:flex;flex-wrap:wrap;gap:8px">
1644
+ <span>当前模式: <span class="badge ${isSandbox?'badge-yellow':'badge-green'}">${isSandbox?'沙盒':'本机'}</span></span>
1645
+ ${isSandbox?`<span>沙盒类型: <span class="tag">${sandboxType==='docker'?'Docker':'轻量级进程'}</span></span>`:''}
1646
+ <span>Docker: <span class="badge ${dockerOk?'badge-green':'badge-red'}">${dockerOk?'可用':'不可用'}</span></span>
1647
+ <span>累计执行: <span class="tag">${e.execution_count||0} 次</span></span>
1648
+ </div>
1649
+ <div style="margin-top:8px;padding:10px 14px;border-radius:var(--radius);background:var(--surface);font-size:12px;color:var(--text2);line-height:1.6">${escHtml(sandboxDesc)}</div>
1650
+ </div>`;
1651
+ // 执行锁状态
1652
+ html+=`<div class="card"><h3>🔒 全局执行锁</h3>
1653
+ <div style="font-size:13px">
1654
+ ${lock.locked?
1655
+ `<span class="badge badge-red">已锁定</span> <strong>${escHtml(lock.locked_by||'')}</strong> — 锁定于 ${escHtml(lock.locked_at||'')}
1656
+ <div style="margin-top:8px"><button class="btn btn-sm btn-danger" onclick="releaseLock()">🔓 释放锁</button></div>`:
1657
+ `<span class="badge badge-green">未锁定</span> — 所有 Agent 可自由运行`
1658
+ }
1659
+ <div style="margin-top:6px;font-size:12px;color:var(--text3)">${isSandbox?'沙盒模式不走全局锁,支持多 Agent 并发执行':'本机模式下多个 Agent 共享此锁,同一时间只有一个 Agent 可以执行代码'}</div>
1660
+ </div></div>`;
1661
+ // 沙盒设置
1638
1662
  html+=`<div class="card"><h3>沙盒设置</h3><div class="form-row">
1639
- <div class="form-group"><label>Docker 镜像</label><input id="sbImage" value="${e.sandbox_image||'python:3.12-slim'}"></div>
1640
- <div class="form-group"><label>内存限制</label><input id="sbMemory" value="${e.sandbox_memory||'512m'}" placeholder="512m"></div>
1663
+ <div class="form-group"><label>Docker 镜像</label><input id="sbImage" value="${escHtml(e.sandbox_image||'python:3.12-slim')}"></div>
1664
+ <div class="form-group"><label>内存限制</label><input id="sbMemory" value="${escHtml(e.sandbox_memory||'512m')}" placeholder="512m"></div>
1641
1665
  <div class="form-group"><label>网络访问</label><select id="sbNetwork"><option ${!e.sandbox_network?'selected':''} value="false">禁止 (更安全)</option><option ${e.sandbox_network?'selected':''} value="true">允许</option></select></div></div>
1642
1666
  <div class="flex gap-8 mt-16"><button class="btn btn-primary" onclick="saveExecutor()">保存设置</button></div></div>`;
1667
+ // 执行参数
1643
1668
  html+=`<div class="card"><h3>执行参数</h3><div class="form-row">
1644
1669
  <div class="form-group"><label>超时时间 (秒)</label><input id="exTimeout" type="number" value="${e.timeout||300}"></div>
1645
- <div class="form-group"><label>自动重试</label><input id="exRetries" type="number" value="2"></div>
1646
- <div class="form-group"><label>自动修复</label><select id="exAutoFix"><option ${e.auto_fix?'selected':''} value="true">开启</option><option ${!e.auto_fix?'selected':''} value="false">关闭</option></select></div></div></div>`;
1670
+ <div class="form-group"><label>自动修复</label><select id="exAutoFix"><option ${e.auto_fix?'selected':''} value="true">开启</option><option ${!e.auto_fix?'selected':''} value="false">关闭</option></select></div>
1671
+ <div class="form-group"><label>最大输出长度</label><input id="exMaxOutput" type="number" value="${e.max_output_length||50000}"></div></div>
1672
+ <div class="flex gap-8 mt-16"><button class="btn btn-primary" onclick="saveExecutor()">保存参数</button></div></div>`;
1647
1673
  $('content').innerHTML=html;
1648
1674
  }
1649
- async function switchMode(mode){const r=await api('/api/executor',{method:'PUT',body:JSON.stringify({execution_mode:mode})});if(!r.ok){showToast('切换失败: '+(r.error||''),'danger');renderExecutor();}else renderExecutor();}
1650
- async function saveExecutor(){await api('/api/executor',{method:'PUT',body:JSON.stringify({sandbox_image:$('sbImage').value,sandbox_memory:$('sbMemory').value,sandbox_network:$('sbNetwork').value==='true',timeout:parseInt($('exTimeout').value),auto_fix:$('exAutoFix').value==='true'})});showToast('已保存','success');renderExecutor();}
1675
+ async function switchMode(mode){const r=await api('/api/executor',{method:'PUT',body:JSON.stringify({execution_mode:mode})});if(!r.ok){showToast('切换失败: '+(r.error||''),'danger');renderExecutor();}else{showToast('已切换','success');renderExecutor();}}
1676
+ async function saveExecutor(){
1677
+ const body={sandbox_image:$('sbImage').value,sandbox_memory:$('sbMemory').value,sandbox_network:$('sbNetwork').value==='true',timeout:parseInt($('exTimeout').value),auto_fix:$('exAutoFix').value==='true',max_output_length:parseInt($('exMaxOutput').value)};
1678
+ const r=await api('/api/executor',{method:'PUT',body:JSON.stringify(body)});
1679
+ if(r.ok)showToast('已保存','success');else showToast('保存失败: '+(r.error||''),'danger');
1680
+ renderExecutor();
1681
+ }
1682
+ async function releaseLock(){
1683
+ const r=await api('/api/execution-lock',{method:'POST',body:JSON.stringify({action:'release'})});
1684
+ if(r.ok)showToast('锁已释放','success');else showToast('释放失败: '+(r.error||''),'danger');
1685
+ renderExecutor();
1686
+ }
1651
1687
 
1652
1688
  // ========== Skills ==========
1653
1689
  async function renderSkills(){
@@ -1719,95 +1755,238 @@ async function viewSkillDetail(name){
1719
1755
  }
1720
1756
 
1721
1757
  // ========== Files ==========
1758
+ var _workdirAgent=''; // 当前选中的 agent(空=全局工作目录)
1722
1759
  async function renderFiles(){
1723
- const wd=await api('/api/workdir');const files=await api('/api/workdir/files');
1724
- let html=`<div class="flex items-center gap-8 mb-16">
1725
- <span style="font-size:14px;color:var(--text2)">工作目录: ${wd.path}</span>
1726
- <button class="btn btn-sm btn-ghost" onclick="changeWorkdir()">更改</button>
1727
- <button class="btn btn-sm btn-ghost" onclick="renderFiles()">刷新</button></div>`;
1728
- html+='<div class="table-wrap"><table><tr><th>名称</th><th>类型</th><th>大小</th></tr>';
1729
- for(const f of (files||[])){
1730
- const icon=f.type==='dir'?'📁':'📄';const size=f.type==='file'?(f.size>1024?(f.size/1024).toFixed(1)+'KB':f.size+'B'):'-';
1731
- html+=`<tr><td>${icon} ${escHtml(f.name)}</td><td>${f.type}</td><td>${size}</td></tr>`;}
1732
- if(!files||!files.length)html+='<tr><td colspan="3" class="empty">目录为空</td></tr>';
1733
- html+='</table></div>';$('content').innerHTML=html;
1734
- }
1735
- async function changeWorkdir(){const p=prompt('新路径:');if(!p)return;await api('/api/workdir',{method:'PUT',body:JSON.stringify({path:p})});renderFiles();}
1760
+ const [wd,agents]=await Promise.all([api('/api/workdir'),api('/api/agents').catch(()=>[])]);
1761
+ const agentList=Array.isArray(agents)?agents.filter(a=>!a.system&&a.path!=='default'):[];
1762
+ let html=`<div class="flex items-center gap-8 mb-16 flex-wrap">
1763
+ <span style="font-size:14px;color:var(--text2)">📁 工作目录: <strong>${escHtml(wd.path||'')}</strong></span>
1764
+ <select id="workdirAgentSelect" onchange="onWorkdirAgentChange()" style="width:auto">
1765
+ <option value="">全局工作目录</option>
1766
+ ${agentList.map(a=>`<option value="${escHtml(a.path)}">${escHtml((a.avatar_emoji||'🤖')+' '+a.name)} (${escHtml(a.path)})</option>`).join('')}
1767
+ </select>
1768
+ <button class="btn btn-sm btn-ghost" onclick="changeWorkdir()">更改全局</button>
1769
+ <button class="btn btn-sm btn-ghost" onclick="renderFiles()">🔄 刷新</button></div>
1770
+ <div id="workdirContent">加载中...</div>`;
1771
+ $('content').innerHTML=html;
1772
+ loadWorkdirContent('');
1773
+ }
1774
+ function onWorkdirAgentChange(){
1775
+ _workdirAgent=$('workdirAgentSelect').value;
1776
+ loadWorkdirContent('');
1777
+ }
1778
+ async function loadWorkdirContent(subPath){
1779
+ const params=new URLSearchParams();
1780
+ if(subPath)params.set('path',subPath);
1781
+ const files=await api('/api/workdir/files?'+params.toString());
1782
+ const el=document.getElementById('workdirContent');
1783
+ if(!el)return;
1784
+ // 面包屑
1785
+ var bcHtml='';
1786
+ if(subPath){
1787
+ var parts=subPath.split('/');var crumbs=['<span style="cursor:pointer;color:var(--primary)" onclick="loadWorkdirContent(\'\')">根目录</span>'];var acc='';
1788
+ for(var i=0;i<parts.length;i++){acc+=(acc?'/':'')+parts[i];crumbs.push(' / <span style="cursor:pointer;color:var(--primary)" onclick="loadWorkdirContent(\''+acc+'\')">'+escHtml(parts[i])+'</span>');}
1789
+ bcHtml=crumbs.join('');
1790
+ }else{bcHtml='<span style="color:var(--text3)">根目录</span>';}
1791
+ // Agent 工作目录信息
1792
+ if(_workdirAgent){
1793
+ var agentCfg=await api('/api/agents/'+encodeURIComponent(_workdirAgent)).catch(()=>null);
1794
+ if(agentCfg&&agentCfg.work_dir){
1795
+ bcHtml+=` <span style="margin-left:12px;font-size:11px;color:var(--text3)">Agent 工作目录: ${escHtml(agentCfg.work_dir)}</span>`;
1796
+ }
1797
+ }
1798
+ var html=`<div style="font-size:12px;margin-bottom:8px">${bcHtml}</div>`;
1799
+ html+='<div class="table-wrap"><table><tr><th>名称</th><th>大小</th><th></th></tr>';
1800
+ if(!files||!files.length){
1801
+ html+='<tr><td colspan="3" class="empty">目录为空</td></tr>';
1802
+ }else{
1803
+ // 排序:目录在前
1804
+ var dirs=files.filter(f=>f.type==='dir').sort((a,b)=>a.name.localeCompare(b.name));
1805
+ var fils=files.filter(f=>f.type==='file').sort((a,b)=>a.name.localeCompare(b.name));
1806
+ for(const d of dirs){
1807
+ var dp=d.path||d.name;
1808
+ html+=`<tr style="cursor:pointer" onclick="loadWorkdirContent('${escHtml(dp)}')"><td>📂 ${escHtml(d.name)}</td><td>-</td><td></td></tr>`;
1809
+ }
1810
+ for(const f of fils){
1811
+ var fp=f.path||f.name;
1812
+ var sizeStr=f.size>1048576?(f.size/1048576).toFixed(1)+' MB':f.size>1024?(f.size/1024).toFixed(1)+' KB':f.size+' B';
1813
+ html+=`<tr><td>📄 ${escHtml(f.name)}</td><td>${sizeStr}</td>
1814
+ <td><button class="btn btn-sm btn-ghost" onclick="downloadWorkdirFileGlobal('${escHtml(fp)}')">下载</button></td></tr>`;
1815
+ }
1816
+ }
1817
+ html+='</table></div>';
1818
+ el.innerHTML=html;
1819
+ }
1820
+ function downloadWorkdirFileGlobal(relPath){
1821
+ var link=document.createElement('a');
1822
+ link.href=API+'/api/workdir/download/'+encodeURIComponent(relPath);
1823
+ link.download='';
1824
+ document.body.appendChild(link);link.click();document.body.removeChild(link);
1825
+ }
1826
+ async function changeWorkdir(){const p=prompt('新路径:');if(!p)return;await api('/api/workdir',{method:'PUT',body:JSON.stringify({path:p})});showToast('已更新','success');renderFiles();}
1736
1827
 
1737
1828
  // ========== Logs ==========
1738
1829
  async function renderLogs(){
1739
- let html=`<div class="flex gap-8 mb-16">
1740
- <select id="logLines" style="width:auto"><option value="100">100 行</option><option value="500" selected>500 行</option><option value="1000">1000 行</option></select>
1830
+ let html=`<div class="flex gap-8 mb-16 flex-wrap">
1831
+ <select id="logLines" style="width:auto"><option value="100">100 行</option><option value="500" selected>500 行</option><option value="1000">1000 行</option><option value="2000">2000 行</option></select>
1832
+ <select id="logLevel" style="width:auto"><option value="">全部级别</option><option value="ERROR">ERROR</option><option value="WARNING">WARNING</option><option value="INFO">INFO</option><option value="DEBUG">DEBUG</option></select>
1741
1833
  <button class="btn btn-primary" onclick="loadLogs()">刷新</button>
1742
- <button class="btn btn-ghost" onclick="toggleLogStream()" id="streamBtn">实时日志</button></div>
1743
- <div class="log-viewer" id="logViewer" style="height:calc(100vh - 220px)">加载中...</div>`;
1834
+ <button class="btn btn-ghost" onclick="toggleLogStream()" id="streamBtn">▶ 实时日志</button>
1835
+ <button class="btn btn-sm btn-ghost" onclick="clearLogViewer()" title="清空显示">🗑️ 清屏</button></div>
1836
+ <div class="log-viewer" id="logViewer" style="height:calc(100vh - 240px);font-family:monospace;font-size:12px;line-height:1.6;overflow-y:auto;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:8px 12px">加载中...</div>`;
1744
1837
  $('content').innerHTML=html;loadLogs();
1745
1838
  }
1746
1839
  async function loadLogs(){
1747
- const lines=$('logLines').value;const logs=await api(`/api/logs?lines=${lines}`);
1748
- $('logViewer').innerHTML=(logs||[]).map(l=>`<div>${escHtml(l)}</div>`).join('');
1749
- $('logViewer').scrollTop=$('logViewer').scrollHeight;
1750
- }
1751
- let logStreamActive=false;
1840
+ const lines=$('logLines').value;const level=$('logLevel').value;
1841
+ const logs=await api(`/api/logs?lines=${lines}${level?'&level='+level:''}`);
1842
+ const viewer=$('logViewer');
1843
+ viewer.innerHTML='';
1844
+ if(!logs||!logs.length){viewer.innerHTML='<div style="color:var(--text3)">暂无日志</div>';return;}
1845
+ for(const l of logs){appendLogLine(viewer,l);}
1846
+ viewer.scrollTop=viewer.scrollHeight;
1847
+ }
1848
+ function appendLogLine(viewer,line){
1849
+ var div=document.createElement('div');
1850
+ div.style.cssText='white-space:pre-wrap;word-break:break-all;padding:1px 0;border-bottom:1px solid var(--border)';
1851
+ // 高亮 ERROR/WARNING
1852
+ if(line.includes('ERROR')||line.includes('CRITICAL')){div.style.color='var(--danger)';div.style.fontWeight='600';}
1853
+ else if(line.includes('WARNING')){div.style.color='#f59e0b';}
1854
+ else if(line.includes('DEBUG')){div.style.color='var(--text3)';}
1855
+ div.textContent=line;
1856
+ viewer.appendChild(div);
1857
+ }
1858
+ function clearLogViewer(){if($('logViewer'))$('logViewer').innerHTML='';}
1859
+ let logStreamActive=false;let _logES=null;
1752
1860
  async function toggleLogStream(){
1753
- if(logStreamActive){logStreamActive=false;$('streamBtn').textContent='实时日志';return;}
1754
- logStreamActive=true;$('streamBtn').textContent='停止实时';
1755
- const es=new EventSource(API+'/api/logs/stream');
1756
- es.onmessage=function(e){try{const lines=JSON.parse(e.data).split('\n');for(const l of lines){$('logViewer').innerHTML+=`<div>${escHtml(l)}</div>`;}$('logViewer').scrollTop=$('logViewer').scrollHeight;while($('logViewer').children.length>2000)$('logViewer').removeChild($('logViewer').firstChild);}catch(ex){}};
1757
- es.onerror=function(){if(logStreamActive)setTimeout(()=>{},3000)};
1861
+ if(logStreamActive){
1862
+ logStreamActive=false;
1863
+ if(_logES){_logES.close();_logES=null;}
1864
+ $('streamBtn').textContent='▶ 实时日志';
1865
+ $('streamBtn').style.background='';return;
1866
+ }
1867
+ logStreamActive=true;$('streamBtn').textContent='⏹ 停止实时';$('streamBtn').style.background='var(--danger)22';
1868
+ _logES=new EventSource(API+'/api/logs/stream');
1869
+ _logES.onmessage=function(e){
1870
+ try{
1871
+ var lines=JSON.parse(e.data).split('\n');
1872
+ var viewer=$('logViewer');if(!viewer)return;
1873
+ for(const l of lines){if(l.trim())appendLogLine(viewer,l);}
1874
+ viewer.scrollTop=viewer.scrollHeight;
1875
+ while(viewer.children.length>3000)viewer.removeChild(viewer.firstChild);
1876
+ }catch(ex){}
1877
+ };
1878
+ // [v1.18.7] 断线自动重连
1879
+ _logES.onerror=function(){
1880
+ if(_logES){_logES.close();_logES=null;}
1881
+ if(logStreamActive){
1882
+ $('streamBtn').textContent='⟳ 重连中...';
1883
+ setTimeout(function(){
1884
+ if(logStreamActive){toggleLogStream();}// 重新连接
1885
+ },3000);
1886
+ }
1887
+ };
1758
1888
  }
1759
1889
 
1760
1890
  // ========== Tasks ==========
1761
1891
  async function renderTasks(){
1762
- const r=await api('/api/tasks');
1763
- const tasks=r.tasks||[];
1764
- const statusCounts={pending:0,running:0,completed:0,failed:0};
1765
- for(const t of tasks){statusCounts[t.status]=(statusCounts[t.status]||0)+1;}
1766
- let html=`<div class="grid grid-4" style="margin-bottom:16px">
1767
- <div class="stat"><div class="label">待处理</div><div class="value">${statusCounts.pending}</div></div>
1768
- <div class="stat"><div class="label">运行中</div><div class="value">${statusCounts.running}</div></div>
1769
- <div class="stat"><div class="label">已完成</div><div class="value" style="color:var(--success)">${statusCounts.completed}</div></div>
1770
- <div class="stat"><div class="label">失败</div><div class="value" style="color:var(--danger)">${statusCounts.failed}</div></div></div>`;
1892
+ // [v1.18.7] 同时获取实时 task plan 和持久化任务
1893
+ const [planR,persistR]=await Promise.all([api('/api/task-plan/all').catch(()=>({plans:[],total_keys:0})),api('/api/tasks').catch(()=>({tasks:[],total:0}))]);
1894
+ const plans=planR.plans||[];
1895
+ const persistTasks=persistR.tasks||[];
1896
+ // 统计
1897
+ var totalPlans=0,totalDone=0,totalPending=0,totalRunning=0;
1898
+ for(const p of plans){totalPlans+=p.total;totalDone+=p.done;totalPending+=p.pending;totalRunning+=p.running;}
1899
+ // 持久化任务统计
1900
+ var ptPending=0,ptRunning=0,ptCompleted=0,ptFailed=0;
1901
+ for(const t of persistTasks){if(t.status==='pending')ptPending++;else if(t.status==='running')ptRunning++;else if(t.status==='completed')ptCompleted++;else if(t.status==='failed')ptFailed++;}
1902
+ let html=`<div class="card" style="margin-bottom:16px"><h3>📊 概览</h3>
1903
+ <div class="grid grid-4" style="margin-top:12px">
1904
+ <div class="stat"><div class="label">活跃 Task Plan</div><div class="value">${plans.length}</div><div style="font-size:11px;color:var(--text3)">${totalPlans} 项任务</div></div>
1905
+ <div class="stat"><div class="label">待执行</div><div class="value">${totalPending}</div><div style="font-size:11px;color:var(--text3)">pending</div></div>
1906
+ <div class="stat"><div class="label">已完成</div><div class="value" style="color:var(--success)">${totalDone}</div><div style="font-size:11px;color:var(--text3)">done</div></div>
1907
+ <div class="stat"><div class="label">执行中</div><div class="value" style="color:var(--info)">${totalRunning}</div><div style="font-size:11px;color:var(--text3)">running</div></div>
1908
+ </div>
1909
+ ${persistTasks.length?`<div style="margin-top:12px;padding-top:12px;border-top:1px solid var(--border);font-size:13px;color:var(--text2)">
1910
+ 持久化任务: ${ptPending} 待处理 · ${ptRunning} 运行中 · <span style="color:var(--success)">${ptCompleted} 已完成</span> · <span style="color:var(--danger)">${ptFailed} 失败</span>
1911
+ </div>`:''}
1912
+ </div>`;
1771
1913
  html+=`<div class="flex gap-8 mb-16 flex-wrap">
1772
- <div style="color:var(--text2);font-size:13px">共 ${tasks.length} 条任务记录</div>
1773
- <select id="taskStatusFilter" onchange="filterTasks()" style="width:auto">
1774
- <option value="">全部状态</option>
1775
- <option value="pending">待处理</option>
1776
- <option value="running">运行中</option>
1777
- <option value="completed">已完成</option>
1778
- <option value="failed">失败</option>
1779
- </select>
1780
- <button class="btn btn-sm btn-ghost" onclick="renderTasks()">刷新</button></div>`;
1781
- if(tasks.length===0){html+='<div class="empty">暂无任务记录</div>';}
1782
- else{
1783
- html+='<div style="max-height:calc(100vh - 340px);overflow-y:auto" id="taskList">';
1784
- html+='<div class="table-wrap"><table><tr><th>任务ID</th><th>描述</th><th>群聊</th><th>状态</th><th>时间</th><th>操作</th></tr>';
1785
- for(const t of tasks){
1786
- const statusBadge=t.status==='completed'?'<span class="badge badge-green">已完成</span>':
1914
+ <button class="btn btn-sm btn-ghost" onclick="renderTasks()">🔄 刷新</button></div>`;
1915
+ // 实时 Task Plan 列表
1916
+ if(plans.length){
1917
+ html+='<h3 style="margin-bottom:8px">📋 实时任务计划 ('+plans.length+' 个活跃)</h3>';
1918
+ for(const p of plans){
1919
+ html+=`<div class="card" style="margin-bottom:12px;padding:12px 16px">
1920
+ <div class="flex justify-between items-center mb-8" style="flex-wrap:wrap;gap:8px">
1921
+ <div><strong>${escHtml(p.label)}</strong> <span class="tag">${p.type==='session'?'会话':'Agent'}</span></div>
1922
+ <div class="flex gap-4" style="font-size:12px">
1923
+ <span class="badge badge-yellow">${p.pending} 待执行</span>
1924
+ <span class="badge badge-blue">${p.running} 执行中</span>
1925
+ <span class="badge badge-green">${p.done} 已完成</span>
1926
+ </div>
1927
+ </div>
1928
+ <div style="margin-top:4px">`;
1929
+ for(var i=0;i<p.tasks.length;i++){
1930
+ var t=p.tasks[i];
1931
+ var st=t.status||'pending';
1932
+ var stBadge=st==='done'?'<span class="badge badge-green" style="font-size:10px">✓ done</span>':
1933
+ st==='running'?'<span class="badge badge-blue" style="font-size:10px">⟳ running</span>':
1934
+ '<span class="badge badge-yellow" style="font-size:10px">○ pending</span>';
1935
+ var checked=st==='done'?'checked':'';
1936
+ html+=`<div style="display:flex;align-items:center;gap:8px;padding:3px 0;font-size:13px;${st==='done'?'text-decoration:line-through;color:var(--text3)':''}">
1937
+ <input type="checkbox" ${checked} style="flex-shrink:0" onclick="toggleTaskPlanItem('${escHtml(p.key)}',${i},this.checked)">
1938
+ <span style="flex:1">${escHtml(t.text||'')}</span>${stBadge}
1939
+ </div>`;
1940
+ }
1941
+ html+=`</div></div>`;
1942
+ }
1943
+ }
1944
+ if(!plans.length){
1945
+ html+='<div class="empty" style="margin-bottom:16px">暂无活跃的实时任务计划</div>';
1946
+ }
1947
+ // 持久化任务列表
1948
+ if(persistTasks.length){
1949
+ html+=`<h3 style="margin-bottom:8px;margin-top:16px">💾 持久化任务 (${persistTasks.length} 条)</h3>`;
1950
+ html+='<div class="table-wrap"><table><tr><th>任务ID</th><th>描述</th><th>来源</th><th>状态</th><th>时间</th><th>操作</th></tr>';
1951
+ for(const t of persistTasks){
1952
+ var statusBadge=t.status==='completed'?'<span class="badge badge-green">已完成</span>':
1787
1953
  t.status==='running'?'<span class="badge badge-blue">运行中</span>':
1788
1954
  t.status==='failed'?'<span class="badge badge-red">失败</span>':
1789
1955
  '<span class="badge badge-yellow">待处理</span>';
1790
- const interrupted=t.metadata?.interrupted;
1791
- const interruptedTag=interrupted?'<span class="badge badge-red" title="进程中断">中断</span>':'';
1792
- const canRetry=t.status==='failed'||t.status==='pending';
1793
- const meta=t.metadata||{};
1794
- const source=meta.source==='group_chat'?'群聊':meta.source||'';
1956
+ var meta=t.metadata||{};
1957
+ var source=meta.source==='group_chat'?'群聊':meta.source||'';
1795
1958
  html+=`<tr data-status="${t.status}">
1796
1959
  <td style="font-family:monospace;font-size:11px;max-width:120px;overflow:hidden;text-overflow:ellipsis" title="${escHtml(t.task_id)}">${escHtml(t.task_id)}</td>
1797
1960
  <td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${escHtml(t.description)}">${escHtml((t.description||'').slice(0,100))}</td>
1798
1961
  <td>${source}${meta.group_name?' / '+escHtml(meta.group_name):''}</td>
1799
- <td>${statusBadge}${interruptedTag}</td>
1962
+ <td>${statusBadge}</td>
1800
1963
  <td style="font-size:12px;white-space:nowrap">${fmtTimeAgo(t.updated_at)}</td>
1801
1964
  <td class="flex gap-8">
1802
- ${canRetry?`<button class="btn btn-sm btn-primary" onclick="retryTask('${escHtml(t.task_id)}')">重试</button>`:''}
1965
+ ${t.status==='failed'||t.status==='pending'?`<button class="btn btn-sm btn-primary" onclick="retryTask('${escHtml(t.task_id)}')">重试</button>`:''}
1803
1966
  <button class="btn btn-sm btn-danger" onclick="deleteTask('${escHtml(t.task_id)}')">删除</button>
1804
1967
  </td></tr>`;
1805
1968
  }
1806
- html+='</table></div></div>';
1969
+ html+='</table></div>';
1970
+ }
1971
+ if(!plans.length&&!persistTasks.length){
1972
+ html+='<div class="empty">暂无任务记录</div>';
1807
1973
  }
1808
1974
  $('content').innerHTML=html;
1809
1975
  }
1810
1976
 
1977
+ async function toggleTaskPlanItem(key,idx,checked){
1978
+ await api('/api/task-plan/all');
1979
+ // 直接调用 task-plan API 修改状态
1980
+ const r=await api('/api/task-plan?agent='+encodeURIComponent(key));
1981
+ if(!r||!r.tasks)return;
1982
+ var tasks=r.tasks;
1983
+ if(idx>=0&&idx<tasks.length){
1984
+ tasks[idx].status=checked?'done':'pending';
1985
+ await api('/api/task-plan',{method:'PUT',body:JSON.stringify({agent:key,session:r.session,tasks:tasks})});
1986
+ renderTasks();
1987
+ }
1988
+ }
1989
+
1811
1990
  function filterTasks(){
1812
1991
  const s=$('taskStatusFilter')?.value||'';
1813
1992
  document.querySelectorAll('#taskList tr').forEach(tr=>{
@@ -2306,12 +2485,23 @@ function endCrop(){_cropState.dragging=false;document.removeEventListener('mouse
2306
2485
  function cancelAvatarCrop(prefix){$(prefix+'CropArea').style.display='none';}
2307
2486
  function confirmAvatarCrop(prefix,agentPath){
2308
2487
  var img=$(prefix+'CropImg'),overlay=$(prefix+'CropOverlay');
2309
- var scale=img.naturalWidth/img.clientWidth;
2310
- var cx=Math.round(parseFloat(overlay.style.left)*scale),cy=Math.round(parseFloat(overlay.style.top)*scale);
2311
- var cw=Math.round(parseFloat(overlay.style.width)*scale),ch=Math.round(parseFloat(overlay.style.height)*scale);
2312
- if(cw<10||ch<10){showToast('请拖动选择裁剪区域','danger');return}
2488
+ if(!img||!overlay){showToast('裁剪数据异常','danger');return}
2489
+ // [v1.18.7] 防止 clientWidth=0 导致 NaN
2490
+ var clientW=Math.max(img.clientWidth,1),clientH=Math.max(img.clientHeight,1);
2491
+ var naturalW=img.naturalWidth||clientW,naturalH=img.naturalHeight||clientH;
2492
+ var scale=naturalW/clientW;
2493
+ if(!isFinite(scale)||scale<=0)scale=1;
2494
+ var cx=Math.round(parseFloat(overlay.style.left||'0')*scale);
2495
+ var cy=Math.round(parseFloat(overlay.style.top||'0')*scale);
2496
+ var cw=Math.round(parseFloat(overlay.style.width||'0')*scale);
2497
+ var ch=Math.round(parseFloat(overlay.style.height||'0')*scale);
2498
+ if(cw<10||ch<10||isNaN(cx)||isNaN(cy)){showToast('请拖动选择裁剪区域','danger');return}
2499
+ var src=$(prefix+'CropImg').src;
2500
+ if(!src||!src.startsWith('data:image')){showToast('请先上传图片','danger');return}
2501
+ var blob=dataURItoBlob(src);
2502
+ if(!blob||blob.size===0){showToast('图片数据异常','danger');return}
2313
2503
  var formData=new FormData();
2314
- formData.append('file',$(prefix+'CropImg').src.split(',')[1]?dataURItoBlob($(prefix+'CropImg').src):'');
2504
+ formData.append('file',blob,'avatar.png');
2315
2505
  showToast('正在上传裁剪...','info');
2316
2506
  fetch('/api/agents/'+encodeURIComponent(agentPath)+'/avatar?crop_x='+cx+'&crop_y='+cy+'&crop_w='+cw+'&crop_h='+ch+'&size=128',{
2317
2507
  method:'POST',body:formData
@@ -2320,7 +2510,15 @@ function confirmAvatarCrop(prefix,agentPath){
2320
2510
  else showToast(d.error||'上传失败','danger');
2321
2511
  }).catch(e=>showToast('上传失败: '+e,'danger'));
2322
2512
  }
2323
- function dataURItoBlob(dataURI){var b=atob(dataURI.split(',')[1]),a=new Uint8Array(b.length);for(var i=0;i<b.length;i++)a[i]=b.charCodeAt(i);return new Blob([a],{type:'image/png'});}
2513
+ function dataURItoBlob(dataURI){
2514
+ if(!dataURI||typeof dataURI!=='string')return null;
2515
+ var parts=dataURI.split(',');
2516
+ if(parts.length<2)return null;
2517
+ var mimeMatch=parts[0].match(/:(.*?);/);
2518
+ var mime=mimeMatch?mimeMatch[1]:'image/png';
2519
+ try{var b=atob(parts[1]),a=new Uint8Array(b.length);for(var i=0;i<b.length;i++)a[i]=b.charCodeAt(i);return new Blob([a],{type:mime});}
2520
+ catch(e){return null}
2521
+ }
2324
2522
  async function removeAvatarImage(agentPath){
2325
2523
  var ad=await api('/api/agents/'+encodeURIComponent(agentPath));if(!ad||!ad.avatar_image)return;
2326
2524
  await api('/api/agents/'+encodeURIComponent(agentPath),{method:'PUT',body:JSON.stringify({avatar_image:''})});