myagent-ai 1.15.18 → 1.15.20

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.15.18",
3
+ "version": "1.15.20",
4
4
  "description": "本地桌面端执行型AI助手 - Open Interpreter 风格 | Local Desktop Execution-Oriented AI Assistant",
5
5
  "main": "main.py",
6
6
  "bin": {
@@ -101,8 +101,13 @@ class WebSearchSkill(Skill):
101
101
 
102
102
  def _fetch():
103
103
  url = "https://html.duckduckgo.com/html/"
104
- r = requests.get(url, params={"q": query}, headers=_HEADERS, timeout=15)
105
- r.raise_for_status()
104
+ try:
105
+ r = requests.get(url, params={"q": query}, headers=_HEADERS, timeout=15)
106
+ r.raise_for_status()
107
+ except requests.exceptions.SSLError:
108
+ logger.warning("DuckDuckGo SSL 验证失败,跳过证书验证重试")
109
+ r = requests.get(url, params={"q": query}, headers=_HEADERS, timeout=15, verify=False)
110
+ r.raise_for_status()
106
111
  return r.text
107
112
 
108
113
  html = await loop.run_in_executor(None, _fetch)
@@ -151,13 +156,24 @@ class WebSearchSkill(Skill):
151
156
 
152
157
  def _fetch():
153
158
  url = "https://www.bing.com/search"
154
- r = requests.get(
155
- url,
156
- params={"q": query, "count": num},
157
- headers=_HEADERS,
158
- timeout=15,
159
- )
160
- r.raise_for_status()
159
+ try:
160
+ r = requests.get(
161
+ url,
162
+ params={"q": query, "count": num},
163
+ headers=_HEADERS,
164
+ timeout=15,
165
+ )
166
+ r.raise_for_status()
167
+ except requests.exceptions.SSLError:
168
+ logger.warning("Bing SSL 验证失败,跳过证书验证重试")
169
+ r = requests.get(
170
+ url,
171
+ params={"q": query, "count": num},
172
+ headers=_HEADERS,
173
+ timeout=15,
174
+ verify=False,
175
+ )
176
+ r.raise_for_status()
161
177
  return r.text
162
178
 
163
179
  html = await loop.run_in_executor(None, _fetch)
@@ -225,8 +241,13 @@ class WebReadSkill(Skill):
225
241
  loop = asyncio.get_event_loop()
226
242
 
227
243
  def _fetch():
228
- r = requests.get(url, headers=_HEADERS, timeout=30)
229
- r.raise_for_status()
244
+ try:
245
+ r = requests.get(url, headers=_HEADERS, timeout=30)
246
+ r.raise_for_status()
247
+ except requests.exceptions.SSLError:
248
+ logger.warning(f"web_read SSL 验证失败 ({url}),跳过证书验证重试")
249
+ r = requests.get(url, headers=_HEADERS, timeout=30, verify=False)
250
+ r.raise_for_status()
230
251
  r.encoding = r.apparent_encoding
231
252
  return r.text
232
253
 
@@ -300,13 +321,24 @@ class URLReadSkill(Skill):
300
321
  loop = asyncio.get_event_loop()
301
322
 
302
323
  def _request():
303
- r = requests.request(
304
- method=method,
305
- url=url,
306
- headers=headers,
307
- data=body,
308
- timeout=30,
309
- )
324
+ try:
325
+ r = requests.request(
326
+ method=method,
327
+ url=url,
328
+ headers=headers,
329
+ data=body,
330
+ timeout=30,
331
+ )
332
+ except requests.exceptions.SSLError:
333
+ logger.warning(f"url_read SSL 验证失败 ({url}),跳过证书验证重试")
334
+ r = requests.request(
335
+ method=method,
336
+ url=url,
337
+ headers=headers,
338
+ data=body,
339
+ timeout=30,
340
+ verify=False,
341
+ )
310
342
  return r
311
343
 
312
344
  response = await loop.run_in_executor(None, _request)
package/web/api_server.py CHANGED
@@ -256,6 +256,8 @@ class ApiServer:
256
256
  r.add_get("/api/agents/{name:[^/]+}/knowledge", self.handle_list_agent_knowledge)
257
257
  r.add_post("/api/agents/{name:[^/]+}/knowledge/upload", self.handle_upload_agent_knowledge)
258
258
  r.add_delete("/api/agents/{name:[^/]+}/knowledge", self.handle_delete_agent_knowledge)
259
+ r.add_post("/api/agents/{name:[^/]+}/avatar", self.handle_upload_agent_avatar)
260
+ r.add_get("/api/agents/{name:[^/]+}/avatar.png", self.handle_get_agent_avatar)
259
261
  # ── Agent 通用 CRUD(放在子资源之后,name 不含斜杠避免吞掉子路由) ──
260
262
  r.add_get("/api/agents/{name:[^/]+}", self.handle_get_agent)
261
263
  r.add_put("/api/agents/{name:[^/]+}", self.handle_update_agent)
@@ -2082,7 +2084,7 @@ class ApiServer:
2082
2084
  "error": f"系统 Agent '{path}' 的灵魂文件和身份文件受保护,不可修改",
2083
2085
  }, status=403)
2084
2086
  # 系统 Agent 只允许修改有限字段
2085
- allowed_fields = ("name", "description", "avatar_color", "avatar_emoji", "model", "system_prompt",
2087
+ allowed_fields = ("name", "description", "avatar_color", "avatar_emoji", "avatar_image", "model", "system_prompt",
2086
2088
  "execution_mode", "enabled", "sandbox_image", "sandbox_network", "sandbox_memory",
2087
2089
  "platform", "platform_token", "platform_app_id", "platform_app_secret",
2088
2090
  "model_id", "backup_model_ids", "work_dir", "department")
@@ -5018,6 +5020,110 @@ class ApiServer:
5018
5020
 
5019
5021
  return web.json_response({"ok": True, "message": "已删除"})
5020
5022
 
5023
+ async def handle_upload_agent_avatar(self, request):
5024
+ """POST /api/agents/{name}/avatar - 上传 Agent 头像图片(支持裁剪)
5025
+
5026
+ 请求格式: multipart/form-data
5027
+ - file: 图片文件 (jpg/png/webp)
5028
+ - crop_x, crop_y, crop_w, crop_h: 裁剪区域(像素,可选,不传则不裁剪)
5029
+ - size: 输出尺寸(像素,默认128,最大512)
5030
+
5031
+ 返回: {"ok": True, "url": "/api/agents/{name}/avatar.png"}
5032
+ """
5033
+ agent_path = request.match_info["name"]
5034
+ ad = self._agent_dir(agent_path)
5035
+ if not (ad / "config.json").exists():
5036
+ return web.json_response({"error": "Agent 不存在"}, status=404)
5037
+
5038
+ try:
5039
+ reader = await request.multipart()
5040
+ field = await reader.next()
5041
+
5042
+ if not field or field.name != "file":
5043
+ return web.json_response({"error": "缺少 file 字段"}, status=400)
5044
+
5045
+ # 读取上传的图片数据
5046
+ image_data = bytearray()
5047
+ while True:
5048
+ chunk = await field.read_chunk()
5049
+ if not chunk:
5050
+ break
5051
+ image_data.extend(chunk)
5052
+
5053
+ # 解析裁剪参数
5054
+ crop_x = int(request.query.get("crop_x", 0))
5055
+ crop_y = int(request.query.get("crop_y", 0))
5056
+ crop_w = int(request.query.get("crop_w", 0))
5057
+ crop_h = int(request.query.get("crop_h", 0))
5058
+ out_size = min(int(request.query.get("size", 128)), 512)
5059
+
5060
+ # 使用 Pillow 处理图片
5061
+ from PIL import Image
5062
+ import io
5063
+
5064
+ img = Image.open(io.BytesIO(bytes(image_data)))
5065
+
5066
+ # 如果有 Alpha 通道,转为 RGB(JPEG 不支持透明)
5067
+ if img.mode in ("RGBA", "P"):
5068
+ # 创建白色背景
5069
+ bg = Image.new("RGB", img.size, (255, 255, 255))
5070
+ if img.mode == "P":
5071
+ img = img.convert("RGBA")
5072
+ bg.paste(img, mask=img.split()[3] if img.mode == "RGBA" else None)
5073
+ img = bg
5074
+ elif img.mode != "RGB":
5075
+ img = img.convert("RGB")
5076
+
5077
+ # 裁剪
5078
+ if crop_w > 0 and crop_h > 0:
5079
+ img = img.crop((crop_x, crop_y, crop_x + crop_w, crop_y + crop_h))
5080
+
5081
+ # 居中裁剪为正方形(如果还不是正方形)
5082
+ w, h = img.size
5083
+ min_side = min(w, h)
5084
+ left = (w - min_side) // 2
5085
+ top = (h - min_side) // 2
5086
+ img = img.crop((left, top, left + min_side, top + min_side))
5087
+
5088
+ # 缩放到目标尺寸
5089
+ img = img.resize((out_size, out_size), Image.LANCZOS)
5090
+
5091
+ # 保存为 PNG
5092
+ avatar_path = ad / "avatar.png"
5093
+ img.save(str(avatar_path), "PNG", optimize=True)
5094
+
5095
+ # 更新 config.json 中的 avatar_image 字段
5096
+ cfg = self._read_agent_config(agent_path)
5097
+ if cfg:
5098
+ cfg["avatar_image"] = f"/api/agents/{agent_path}/avatar.png"
5099
+ (ad / "config.json").write_text(
5100
+ json.dumps(cfg, indent=2, ensure_ascii=False), encoding="utf-8"
5101
+ )
5102
+
5103
+ # 确保头像文件可被静态访问:注册 avatar 路由
5104
+ avatar_url = f"/api/agents/{agent_path}/avatar.png"
5105
+
5106
+ return web.json_response({
5107
+ "ok": True,
5108
+ "url": avatar_url,
5109
+ "size": out_size,
5110
+ })
5111
+
5112
+ except ImportError:
5113
+ return web.json_response({"error": "需要 Pillow 库: pip install Pillow"}, status=500)
5114
+ except Exception as e:
5115
+ logger.error(f"头像上传失败 ({agent_path}): {e}", exc_info=True)
5116
+ return web.json_response({"error": f"头像上传失败: {e}"}, status=500)
5117
+
5118
+ async def handle_get_agent_avatar(self, request):
5119
+ """GET /api/agents/{name}/avatar.png - 获取 Agent 头像图片"""
5120
+ agent_path = request.match_info["name"]
5121
+ ad = self._agent_dir(agent_path)
5122
+ avatar_file = ad / "avatar.png"
5123
+ if not avatar_file.exists():
5124
+ return web.json_response({"error": "头像不存在"}, status=404)
5125
+ return web.FileResponse(str(avatar_file))
5126
+
5021
5127
  # ── 知识库 RAG 搜索 ──
5022
5128
 
5023
5129
  async def handle_knowledge_search(self, request):
@@ -5253,14 +5359,62 @@ class ApiServer:
5253
5359
  model_chain = self._build_model_chain(agent_cfg, agent_path)
5254
5360
  session_id = f"group_{gid}_{agent_path}"
5255
5361
 
5362
+ # [v1.15.18] 构建 Agent 专属系统提示词(与1:1聊天一致)
5363
+ _, agent_system_prompt = self._build_agent_chat_context(agent_path, agent_cfg, content)
5364
+
5365
+ # [v1.15.18] 构建群聊上下文:群信息 + 成员列表 + 发言者身份
5366
+ member_lines = []
5367
+ for m in group.members:
5368
+ mc = self._read_agent_config(m.agent_path)
5369
+ m_name = mc.get("name", m.agent_path) if mc else m.agent_path
5370
+ m_desc = mc.get("description", "") if mc else ""
5371
+ role_label = {"owner": "群主", "admin": "管理员"}.get(m.role, "成员")
5372
+ nick = f"(昵称: {m.nickname})" if m.nickname else ""
5373
+ line = f" - {m_name} [{m.agent_path}] ({role_label})"
5374
+ if m_desc:
5375
+ line += f" — {m_desc}"
5376
+ line += nick
5377
+ if m.muted:
5378
+ line += "(已禁言)"
5379
+ member_lines.append(line)
5380
+
5381
+ my_name = agent_cfg.get("name", agent_path) if agent_cfg else agent_path
5382
+ my_role = {"owner": "群主", "admin": "管理员"}.get(member.role, "成员")
5383
+ my_desc = agent_cfg.get("description", "") if agent_cfg else ""
5384
+
5385
+ group_context = (
5386
+ f"## 群聊上下文\n"
5387
+ f"- 群名称: {group.name}\n"
5388
+ f"- 群ID: {group.id}\n"
5389
+ f"- 群描述: {group.description}\n"
5390
+ f"- 当前发言者: 用户\n"
5391
+ f"- 你的身份: {my_name} ({my_role})"
5392
+ + (f" — {my_desc}" if my_desc else "")
5393
+ + f"\n- 群成员 ({len(group.members)}人):\n"
5394
+ + "\n".join(member_lines)
5395
+ + "\n\n注意:你只代表自己发言,回复时使用第一人称。"
5396
+ "如果消息不是跟你相关的,可以简短回复或不回复。"
5397
+ )
5398
+
5399
+ # 将群聊上下文追加到 agent_system_prompt
5400
+ if agent_system_prompt:
5401
+ agent_system_prompt += "\n\n" + group_context
5402
+ else:
5403
+ agent_system_prompt = group_context
5404
+
5256
5405
  # 构建部门上下文(如果此群属于某个部门)
5257
- agent_content = content
5258
5406
  dept_context = self._build_dept_context(gid, agent_path)
5259
5407
  if dept_context:
5260
- agent_content = dept_context + "\n\n---\n\n" + content
5408
+ agent_system_prompt += "\n\n" + dept_context
5409
+
5410
+ # 构建最终消息(用户原文不包含任何注入内容)
5411
+ agent_content = content
5261
5412
 
5262
5413
  if model_chain and self.core.llm:
5263
- response = await self._try_model_chain(model_chain, agent_content, session_id)
5414
+ response = await self._try_model_chain(
5415
+ model_chain, agent_content, session_id,
5416
+ agent_path=agent_path, agent_system_prompt=agent_system_prompt,
5417
+ )
5264
5418
  else:
5265
5419
  response = await self.core.process_message(agent_content, session_id)
5266
5420
 
@@ -5446,14 +5600,55 @@ class ApiServer:
5446
5600
  model_chain = self._build_model_chain(agent_cfg, agent_path)
5447
5601
  session_id = f"group_{group_id}_{agent_path}"
5448
5602
 
5449
- # 构建部门上下文
5450
- agent_content = description
5603
+ # [v1.15.18] 构建 Agent 专属系统提示词 + 群聊上下文(与发送群消息一致)
5604
+ _, agent_system_prompt = self._build_agent_chat_context(agent_path, agent_cfg, description)
5605
+
5606
+ # 构建群聊上下文
5607
+ member_lines = []
5608
+ for m in group.members:
5609
+ mc = self._read_agent_config(m.agent_path)
5610
+ m_name = mc.get("name", m.agent_path) if mc else m.agent_path
5611
+ m_desc = mc.get("description", "") if mc else ""
5612
+ role_label = {"owner": "群主", "admin": "管理员"}.get(m.role, "成员")
5613
+ line = f" - {m_name} [{m.agent_path}] ({role_label})"
5614
+ if m_desc:
5615
+ line += f" — {m_desc}"
5616
+ member_lines.append(line)
5617
+
5618
+ my_name = agent_cfg.get("name", agent_path) if agent_cfg else agent_path
5619
+ my_role = {"owner": "群主", "admin": "管理员"}.get(member.role, "成员")
5620
+ my_desc = agent_cfg.get("description", "") if agent_cfg else ""
5621
+
5622
+ group_context = (
5623
+ f"## 群聊上下文\n"
5624
+ f"- 群名称: {group.name}\n"
5625
+ f"- 群ID: {group.id}\n"
5626
+ f"- 群描述: {group.description}\n"
5627
+ f"- 当前发言者: 用户(重试任务)\n"
5628
+ f"- 你的身份: {my_name} ({my_role})"
5629
+ + (f" — {my_desc}" if my_desc else "")
5630
+ + f"\n- 群成员 ({len(group.members)}人):\n"
5631
+ + "\n".join(member_lines)
5632
+ + "\n\n注意:你只代表自己发言,回复时使用第一人称。"
5633
+ "如果消息不是跟你相关的,可以简短回复或不回复。"
5634
+ )
5635
+
5636
+ if agent_system_prompt:
5637
+ agent_system_prompt += "\n\n" + group_context
5638
+ else:
5639
+ agent_system_prompt = group_context
5640
+
5451
5641
  dept_context = self._build_dept_context(group_id, agent_path)
5452
5642
  if dept_context:
5453
- agent_content = dept_context + "\n\n---\n\n" + description
5643
+ agent_system_prompt += "\n\n" + dept_context
5644
+
5645
+ agent_content = description
5454
5646
 
5455
5647
  if model_chain and self.core.llm:
5456
- response = await self._try_model_chain(model_chain, agent_content, session_id)
5648
+ response = await self._try_model_chain(
5649
+ model_chain, agent_content, session_id,
5650
+ agent_path=agent_path, agent_system_prompt=agent_system_prompt,
5651
+ )
5457
5652
  else:
5458
5653
  response = await self.core.process_message(agent_content, session_id)
5459
5654
 
@@ -249,6 +249,20 @@ function getAgentGradient(name) {
249
249
  return `linear-gradient(135deg,${palettes[idx][0]},${palettes[idx][1]})`;
250
250
  }
251
251
 
252
+ /** 渲染 Agent 头像 HTML — 支持 avatar_image(图片)和 avatar_emoji(emoji)双模式 */
253
+ function avatarHtml(agent, size, extraStyle) {
254
+ if (!agent) return '';
255
+ const s = size || 36;
256
+ const style = (extraStyle || '') + 'width:' + s + 'px;height:' + s + 'px;border-radius:50%;object-fit:cover;display:block';
257
+ if (agent.avatar_image) {
258
+ return '<img src="' + escapeHtml(agent.avatar_image) + '" style="' + style + '" onerror="this.style.display=\'none\'">';
259
+ }
260
+ const emoji = agent.avatar_emoji || agent.emoji || getAgentInitials(agent.name);
261
+ const bg = agent.avatar_color ? agent.avatar_color + '22' : 'transparent';
262
+ const border = agent.avatar_color ? '2px solid ' + agent.avatar_color : 'none';
263
+ return '<div style="' + style + ';background:' + bg + ';border:' + border + ';display:flex;align-items:center;justify-content:center;font-size:' + Math.round(s * 0.55) + 'px">' + escapeHtml(emoji) + '</div>';
264
+ }
265
+
252
266
  // ── State Persistence (localStorage) ──
253
267
  const StatePersistence = {
254
268
  PREFIX: 'myagent_',
@@ -988,9 +1002,10 @@ function renderMasterAgentCard() {
988
1002
  var color = agent ? (agent.avatar_color || 'linear-gradient(135deg,#6366f1,#8b5cf6)') : 'linear-gradient(135deg,#6366f1,#8b5cf6)';
989
1003
  var bgStyle = color.includes('gradient') ? 'background:' + color : '';
990
1004
  var bgClass = color.includes('gradient') ? '' : getAgentColorClass('default');
1005
+ var avatarContent = (agent && agent.avatar_image) ? '<img src="' + escapeHtml(agent.avatar_image) + '" style="width:100%;height:100%;object-fit:cover;border-radius:12px">' : emoji;
991
1006
 
992
1007
  el.innerHTML = '<div class="rp-master-card ' + (isActive ? 'active' : '') + '" onclick="selectAgent(\'default\')">'
993
- + '<div class="rp-master-avatar ' + bgClass + '" style="' + bgStyle + '">' + emoji + '</div>'
1008
+ + '<div class="rp-master-avatar ' + bgClass + '" style="' + bgStyle + '">' + avatarContent + '</div>'
994
1009
  + '<div class="rp-master-info">'
995
1010
  + '<div class="rp-master-name">' + escapeHtml(name) + '</div>'
996
1011
  + '<div class="rp-master-desc">' + escapeHtml(desc) + '</div>'
@@ -2456,7 +2471,7 @@ function _renderMessagesInner() {
2456
2471
  // Skip standalone tool messages (now grouped into assistant parts via groupHistoryMessages)
2457
2472
  if (msg.role === 'tool') continue;
2458
2473
 
2459
- const avatar = isUser ? '👤' : botEmoji;
2474
+ const avatar = isUser ? '<span style="font-size:18px">👤</span>' : avatarHtml({avatar_image: state.currentAgent?.avatar_image, avatar_emoji: botEmoji, avatar_color: state.currentAgent?.avatar_color, name: state.currentAgent?.name}, 32, 'border-radius:8px;');
2460
2475
  const content = renderMarkdown(msg.content);
2461
2476
  const thoughtHtml = msg.thought ? (() => {
2462
2477
  const isStreaming = !!msg.streaming;
package/web/ui/index.html CHANGED
@@ -378,9 +378,10 @@ function agentCardHtml(a,deptMap){
378
378
  const dept=deptMap?.[a.department]||null;
379
379
  const emoji=a.avatar_emoji||'🤖';
380
380
  const color=a.avatar_color||'#6366f1';
381
+ const avatarHtml=a.avatar_image?'<img src="'+escHtml(a.avatar_image)+'" style="width:100%;height:100%;object-fit:cover">':emoji;
381
382
  const enabled=a.enabled!==false;
382
383
  return `<div class="agent-card" data-name="${escHtml(a.name||'')}" data-desc="${escHtml(a.description||'')}">
383
- <div class="avatar" style="background:${color}22;border:2px solid ${color}">${emoji}</div>
384
+ <div class="avatar" style="background:${color}22;border:2px solid ${color};overflow:hidden">${avatarHtml}</div>
384
385
  <div class="info">
385
386
  <h4>${escHtml(a.name||a.path)} ${isSys?'<span class="badge badge-purple">系统</span>':''} ${!enabled?'<span class="badge badge-red">已禁用</span>':''}</h4>
386
387
  <p>${escHtml(a.description||'无描述')}</p>
@@ -424,7 +425,30 @@ function openCreateAgentModal(){
424
425
  <div class="form-group"><label>描述</label><input id="caDesc" placeholder="Agent 描述"></div>
425
426
  </div>
426
427
  <div class="form-row">
427
- <div class="form-group"><label>头像 Emoji</label><input id="caEmoji" placeholder="🤖" maxlength="4"></div>
428
+ <div class="form-group"><label>头像</label>
429
+ <div class="flex gap-8 items-center" style="flex-wrap:wrap">
430
+ <div id="caAvatarPreview" class="avatar" style="width:48px;height:48px;background:#6366f122;border:2px solid #6366f1;font-size:24px">🤖</div>
431
+ <div style="flex:1;min-width:200px">
432
+ <div class="flex gap-8 items-center">
433
+ <input id="caEmoji" placeholder="🤖" maxlength="4" style="width:60px" oninput="$('caAvatarPreview').textContent=this.value||'🤖'">
434
+ <label class="btn btn-sm" style="cursor:pointer"><input type="file" accept="image/*" hidden onchange="handleAvatarUpload(this,'ca')">
435
+ 📷 上传图片
436
+ </label>
437
+ </div>
438
+ <div id="caCropArea" style="display:none;margin-top:8px">
439
+ <div style="position:relative;display:inline-block">
440
+ <img id="caCropImg" style="max-width:300px;max-height:200px;border:1px solid #ddd;cursor:crosshair" onmousedown="startCrop(event,'ca')">
441
+ <div id="caCropOverlay" style="position:absolute;border:2px dashed #4f46e5;background:rgba(79,70,229,0.15);display:none;pointer-events:none"></div>
442
+ </div>
443
+ <div class="flex gap-8 mt-8">
444
+ <button class="btn btn-sm btn-primary" onclick="confirmAvatarCrop('ca','${name||''}')">✂️ 裁剪并使用</button>
445
+ <button class="btn btn-sm btn-ghost" onclick="cancelAvatarCrop('ca')">取消</button>
446
+ </div>
447
+ </div>
448
+ <input type="hidden" id="caAvatarImage" value="">
449
+ </div>
450
+ </div>
451
+ </div>
428
452
  <div class="form-group"><label>头像颜色</label><div class="flex gap-8 items-center"><input id="caColor" value="#6366f1" type="color" style="width:48px;height:34px;padding:2px"><input id="caColorText" value="#6366f1" style="flex:1" oninput="$('caColor').value=this.value"></div></div>
429
453
  </div>
430
454
  <div class="form-row">
@@ -484,7 +508,31 @@ async function openEditAgentModal(path){
484
508
  <div class="form-group"><label>描述</label><input id="eaDesc" value="${escHtml(a.description||'')}" ${isSys?'disabled':''}></div>
485
509
  </div>
486
510
  <div class="form-row">
487
- <div class="form-group"><label>头像 Emoji</label><input id="eaEmoji" value="${escHtml(a.avatar_emoji||'')}" ${isSys?'disabled':''}></div>
511
+ <div class="form-group"><label>头像</label>
512
+ <div class="flex gap-8 items-center" style="flex-wrap:wrap">
513
+ <div id="eaAvatarPreview" class="avatar" style="width:48px;height:48px;background:${escHtml(a.avatar_color||'#6366f1')}22;border:2px solid ${escHtml(a.avatar_color||'#6366f1')};font-size:24px;overflow:hidden">${a.avatar_image?'<img src="'+escHtml(a.avatar_image)+'" style="width:100%;height:100%;object-fit:cover">':escHtml(a.avatar_emoji||'🤖')}</div>
514
+ <div style="flex:1;min-width:200px">
515
+ <div class="flex gap-8 items-center">
516
+ <input id="eaEmoji" value="${escHtml(a.avatar_emoji||'')}" style="width:60px" oninput="if(!$('eaAvatarImage').value){$('eaAvatarPreview').textContent=this.value||'🤖';$('eaAvatarPreview').innerHTML=''}" ${isSys?'disabled':''}>
517
+ <label class="btn btn-sm" style="cursor:pointer" ${isSys?'style=\"pointer-events:none;opacity:0.5\"':''}><input type="file" accept="image/*" hidden onchange="handleAvatarUpload(this,'ea')" ${isSys?'disabled':''}>
518
+ 📷 上传图片
519
+ </label>
520
+ ${a.avatar_image?'<button class="btn btn-sm btn-ghost" onclick="removeAvatarImage(\\''+escHtml(path)+'\\')">🗑️ 移除图片</button>':''}
521
+ </div>
522
+ <div id="eaCropArea" style="display:none;margin-top:8px">
523
+ <div style="position:relative;display:inline-block">
524
+ <img id="eaCropImg" style="max-width:300px;max-height:200px;border:1px solid #ddd;cursor:crosshair" onmousedown="startCrop(event,'ea')">
525
+ <div id="eaCropOverlay" style="position:absolute;border:2px dashed #4f46e5;background:rgba(79,70,229,0.15);display:none;pointer-events:none"></div>
526
+ </div>
527
+ <div class="flex gap-8 mt-8">
528
+ <button class="btn btn-sm btn-primary" onclick="confirmAvatarCrop('ea','${escHtml(path)}')">✂️ 裁剪并使用</button>
529
+ <button class="btn btn-sm btn-ghost" onclick="cancelAvatarCrop('ea')">取消</button>
530
+ </div>
531
+ </div>
532
+ <input type="hidden" id="eaAvatarImage" value="${escHtml(a.avatar_image||'')}">
533
+ </div>
534
+ </div>
535
+ </div>
488
536
  <div class="form-group"><label>头像颜色</label><div class="flex gap-8 items-center"><input id="eaColor" value="${escHtml(a.avatar_color||'#6366f1')}" type="color" style="width:48px;height:34px;padding:2px" ${isSys?'disabled':''}><input id="eaColorText" value="${escHtml(a.avatar_color||'#6366f1')}" style="flex:1" oninput="$('eaColor').value=this.value" ${isSys?'disabled':''}></div></div>
489
537
  </div>
490
538
  <div class="form-group"><label>绑定模型</label><select id="eaModelId"><option value="">使用全局默认</option>${modelOpts}</select></div>
@@ -1897,6 +1945,63 @@ async function sysLoadPreview(){
1897
1945
  if(pages[page])showPage(page);else showPage('dashboard');
1898
1946
  })();
1899
1947
  setInterval(()=>{api('/api/status').catch(()=>{})},30000);
1948
+
1949
+ /* ── 头像上传 + 裁剪 ── */
1950
+ var _cropState={prefix:'',dragging:false,sx:0,sy:0};
1951
+ function handleAvatarUpload(input,prefix){
1952
+ var file=input.files[0];if(!file)return;
1953
+ if(file.size>5*1024*1024){showToast('图片不能超过 5MB','danger');return}
1954
+ var reader=new FileReader();
1955
+ reader.onload=function(e){
1956
+ var img=$(prefix+'CropImg');img.src=e.target.result;
1957
+ $(prefix+'CropArea').style.display='block';
1958
+ $(prefix+'CropOverlay').style.display='none';
1959
+ _cropState.prefix=prefix;
1960
+ };
1961
+ reader.readAsDataURL(file);
1962
+ }
1963
+ function startCrop(e,prefix){
1964
+ _cropState={prefix:prefix,dragging:true,sx:e.offsetX,sy:e.offsetY};
1965
+ var overlay=$(prefix+'CropOverlay');
1966
+ overlay.style.display='block';
1967
+ overlay.style.left=e.offsetX+'px';overlay.style.top=e.offsetY+'px';
1968
+ overlay.style.width='0';overlay.style.height='0';
1969
+ document.addEventListener('mousemove',doCrop);document.addEventListener('mouseup',endCrop);
1970
+ }
1971
+ function doCrop(e){
1972
+ if(!_cropState.dragging)return;
1973
+ var img=$(_cropState.prefix+'CropImg'),rect=img.getBoundingClientRect();
1974
+ var overlay=$(_cropState.prefix+'CropOverlay');
1975
+ var x1=Math.min(_cropState.sx,e.clientX-rect.left),y1=Math.min(_cropState.sy,e.clientY-rect.top);
1976
+ var x2=Math.max(_cropState.sx,e.clientX-rect.left),y2=Math.max(_cropState.sy,e.clientY-rect.top);
1977
+ x2=Math.min(x2,rect.width);y2=Math.min(y2,rect.height);
1978
+ var w=x2-x1,h=y2-y1;if(w<5||h<5)return;
1979
+ overlay.style.left=x1+'px';overlay.style.top=y1+'px';overlay.style.width=w+'px';overlay.style.height=h+'px';
1980
+ }
1981
+ function endCrop(){_cropState.dragging=false;document.removeEventListener('mousemove',doCrop);document.removeEventListener('mouseup',endCrop);}
1982
+ function cancelAvatarCrop(prefix){$(prefix+'CropArea').style.display='none';}
1983
+ function confirmAvatarCrop(prefix,agentPath){
1984
+ var img=$(prefix+'CropImg'),overlay=$(prefix+'CropOverlay');
1985
+ var scale=img.naturalWidth/img.clientWidth;
1986
+ var cx=Math.round(parseFloat(overlay.style.left)*scale),cy=Math.round(parseFloat(overlay.style.top)*scale);
1987
+ var cw=Math.round(parseFloat(overlay.style.width)*scale),ch=Math.round(parseFloat(overlay.style.height)*scale);
1988
+ if(cw<10||ch<10){showToast('请拖动选择裁剪区域','danger');return}
1989
+ var formData=new FormData();
1990
+ formData.append('file',$(prefix+'CropImg').src.split(',')[1]?dataURItoBlob($(prefix+'CropImg').src):'');
1991
+ showToast('正在上传裁剪...','info');
1992
+ fetch('/api/agents/'+encodeURIComponent(agentPath)+'/avatar?crop_x='+cx+'&crop_y='+cy+'&crop_w='+cw+'&crop_h='+ch+'&size=128',{
1993
+ method:'POST',body:formData
1994
+ }).then(r=>r.json()).then(d=>{
1995
+ if(d.ok){$(prefix+'AvatarImage').value=d.url;$(prefix+'AvatarPreview').innerHTML='<img src="'+d.url+'" style="width:100%;height:100%;object-fit:cover">';$(prefix+'CropArea').style.display='none';showToast('头像已更新','success');}
1996
+ else showToast(d.error||'上传失败','danger');
1997
+ }).catch(e=>showToast('上传失败: '+e,'danger'));
1998
+ }
1999
+ 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'});}
2000
+ async function removeAvatarImage(agentPath){
2001
+ var ad=await api('/api/agents/'+encodeURIComponent(agentPath));if(!ad||!ad.avatar_image)return;
2002
+ await api('/api/agents/'+encodeURIComponent(agentPath),{method:'PUT',body:JSON.stringify({avatar_image:''})});
2003
+ $('eaAvatarImage').value='';$('eaAvatarPreview').innerHTML=escHtml(ad.avatar_emoji||'🤖');showToast('已移除头像图片','success');
2004
+ }
1900
2005
  </script>
1901
2006
  </body>
1902
2007
  </html>