myagent-ai 1.18.6 → 1.18.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "myagent-ai",
3
- "version": "1.18.6",
3
+ "version": "1.18.7",
4
4
  "description": "本地桌面端执行型AI助手 - Open Interpreter 风格 | Local Desktop Execution-Oriented AI Assistant",
5
5
  "main": "main.py",
6
6
  "bin": {
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)
@@ -2103,6 +2104,36 @@ window.toggleFullscreen = function() {{
2103
2104
  self._task_list_store[store_key] = tasks
2104
2105
  return web.json_response({"ok": True, "tasks": tasks, "session": session_id})
2105
2106
 
2107
+ async def handle_get_all_task_plans(self, request):
2108
+ """GET /api/task-plan/all - 获取所有活跃的 task plan(用于后台管理任务列表)。
2109
+
2110
+ 返回所有 _task_list_store 中的非空任务列表,包含 session_id/agent_path 信息。
2111
+ """
2112
+ all_plans = []
2113
+ for key, tasks in self._task_list_store.items():
2114
+ if not tasks:
2115
+ continue
2116
+ # 统计状态
2117
+ status_counts = {"pending": 0, "running": 0, "done": 0}
2118
+ for t in tasks:
2119
+ s = t.get("status", "pending")
2120
+ status_counts[s] = status_counts.get(s, 0) + 1
2121
+ # 判断 key 类型
2122
+ is_session = key.startswith(("web_", "cli_", "group_", "tg_", "discord_", "feishu_"))
2123
+ all_plans.append({
2124
+ "key": key,
2125
+ "type": "session" if is_session else "agent",
2126
+ "total": len(tasks),
2127
+ "pending": status_counts["pending"],
2128
+ "running": status_counts["running"],
2129
+ "done": status_counts["done"],
2130
+ "tasks": tasks,
2131
+ "label": key.split("_web_")[0][:30] if is_session else key,
2132
+ })
2133
+ # 按 total 降序
2134
+ all_plans.sort(key=lambda x: x["total"], reverse=True)
2135
+ return web.json_response({"plans": all_plans, "total_keys": len(self._task_list_store)})
2136
+
2106
2137
  async def handle_shutdown(self, request):
2107
2138
  self.core._running = False
2108
2139
  asyncio.create_task(self.core.shutdown())
@@ -2830,13 +2861,31 @@ window.toggleFullscreen = function() {{
2830
2861
  async def handle_get_executor(self, request):
2831
2862
  info = self.core.executor.get_execution_info() if self.core.executor else {}
2832
2863
  cfg = self.core.config.executor if self.core.config else None
2864
+ # [v1.18.7] 附加执行锁状态和沙盒说明
2865
+ lock = self._execution_lock
2833
2866
  return web.json_response({
2834
2867
  **info,
2835
2868
  "timeout": cfg.timeout if cfg else 300,
2836
2869
  "auto_fix": cfg.auto_fix if cfg else True,
2837
2870
  "max_output_length": cfg.max_output_length if cfg else 50000,
2871
+ # 执行锁信息
2872
+ "lock": {
2873
+ "locked": lock["locked"],
2874
+ "locked_by": lock["locked_by"],
2875
+ "locked_at": lock["locked_at"],
2876
+ },
2877
+ # 沙盒模式说明
2878
+ "sandbox_desc": self._sandbox_description(info.get("mode"), info.get("sandbox_type"), info.get("docker_available")),
2838
2879
  })
2839
2880
 
2881
+ def _sandbox_description(self, mode, sandbox_type, docker_available):
2882
+ """生成沙盒模式说明文本"""
2883
+ if mode != "sandbox":
2884
+ return "本机模式: 代码直接在宿主机运行,拥有完整系统权限。多个 Agent 共享全局执行锁,同一时间只有一个 Agent 可以执行代码。"
2885
+ if docker_available:
2886
+ return "Docker 沙盒: 代码在 Docker 容器中运行,通过网络隔离保证安全性。容器使用指定镜像,执行完毕后自动销毁。不走全局锁,支持多 Agent 并发。"
2887
+ return "轻量级进程沙盒: Docker 不可用时的降级方案。代码在临时工作目录中通过子进程执行,提供目录隔离。不走全局锁,支持多 Agent 并发。安全性低于 Docker 沙盒。"
2888
+
2840
2889
  async def handle_update_executor(self, request):
2841
2890
  data = await request.json()
2842
2891
  cfg_path = self.core.config_mgr._config_file
@@ -3852,35 +3901,65 @@ window.toggleFullscreen = function() {{
3852
3901
  lines = int(request.query.get("lines", "200"))
3853
3902
  level = request.query.get("level", "").upper()
3854
3903
  logs = []
3855
- for lf in sorted(log_dir.glob("myagent*.log"), reverse=True):
3904
+ # [v1.18.7] 遍历所有 .log 文件(不仅限于 myagent*.log),按修改时间倒序
3905
+ try:
3906
+ all_log_files = sorted(
3907
+ [f for f in log_dir.glob("*.log") if f.is_file()],
3908
+ key=lambda f: f.stat().st_mtime,
3909
+ reverse=True,
3910
+ )
3911
+ except Exception:
3912
+ all_log_files = []
3913
+ for lf in all_log_files:
3856
3914
  try:
3857
3915
  text = lf.read_text(encoding="utf-8", errors="ignore")
3858
3916
  for line in text.strip().split("\n")[-lines:]:
3859
- if level and level not in line: continue
3917
+ if level and level not in line:
3918
+ continue
3860
3919
  logs.append(line)
3861
- if len(logs) >= lines: break
3862
- except: pass
3920
+ if len(logs) >= lines:
3921
+ break
3922
+ except Exception:
3923
+ pass
3863
3924
  return web.json_response(logs[-lines:])
3864
3925
 
3865
3926
  async def handle_log_stream(self, request):
3866
3927
  resp = web.StreamResponse()
3867
3928
  resp.content_type = "text/event-stream"
3868
3929
  resp.headers["Cache-Control"] = "no-cache"
3930
+ resp.headers["Connection"] = "keep-alive"
3869
3931
  await resp.prepare(request)
3870
- log_file = self.core.config_mgr.logs_dir / "myagent.log"
3932
+ log_dir = self.core.config_mgr.logs_dir
3933
+ # [v1.18.7] 自动发现最新的日志文件而非硬编码 myagent.log
3871
3934
  last_pos = 0
3935
+ last_file = None
3872
3936
  try:
3873
3937
  while True:
3874
3938
  try:
3875
- if log_file.exists():
3876
- size = log_file.stat().st_size
3939
+ # 每 5 秒重新扫描最新日志文件(应对日志轮转)
3940
+ current_file = None
3941
+ if log_dir.exists():
3942
+ candidates = [f for f in log_dir.glob("*.log") if f.is_file()]
3943
+ if candidates:
3944
+ current_file = max(candidates, key=lambda f: f.stat().st_mtime)
3945
+ if current_file:
3946
+ # 日志文件切换时重置位置
3947
+ if last_file and current_file.resolve() != last_file.resolve():
3948
+ last_pos = 0
3949
+ last_file = current_file
3950
+ size = current_file.stat().st_size
3877
3951
  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
3952
+ with open(current_file, "r", encoding="utf-8", errors="ignore") as f:
3953
+ f.seek(last_pos)
3954
+ new_data = f.read()
3955
+ last_pos = size
3956
+ if new_data:
3957
+ await resp.write(f"data: {json.dumps(new_data.strip())}\n\n")
3958
+ except Exception:
3959
+ pass
3882
3960
  await asyncio.sleep(0.5)
3883
- except asyncio.CancelledError: pass
3961
+ except asyncio.CancelledError:
3962
+ pass
3884
3963
  return resp
3885
3964
 
3886
3965
  # ── 配置管理 (热重载 / 导入 / 导出) ──
@@ -6127,11 +6206,14 @@ window.toggleFullscreen = function() {{
6127
6206
  break
6128
6207
  image_data.extend(chunk)
6129
6208
 
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))
6209
+ # 解析裁剪参数([v1.18.7] 防止 NaN 传入导致 ValueError)
6210
+ try:
6211
+ crop_x = int(float(request.query.get("crop_x", 0)))
6212
+ crop_y = int(float(request.query.get("crop_y", 0)))
6213
+ crop_w = int(float(request.query.get("crop_w", 0)))
6214
+ crop_h = int(float(request.query.get("crop_h", 0)))
6215
+ except (ValueError, TypeError):
6216
+ crop_x = crop_y = crop_w = crop_h = 0
6135
6217
  out_size = min(int(request.query.get("size", 128)), 512)
6136
6218
 
6137
6219
  # 使用 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:''})});