myagent-ai 1.16.11 → 1.16.13

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/agents/base.py CHANGED
@@ -242,7 +242,9 @@ class BaseAgent(ABC):
242
242
  if role == "system":
243
243
  system_msg = content
244
244
  continue
245
- anth_messages.append({"role": role, "content": content})
245
+ # 转换 OpenAI Vision 格式为 Anthropic 格式
246
+ anth_content = self.llm._convert_to_anthropic_content(content)
247
+ anth_messages.append({"role": role, "content": anth_content})
246
248
 
247
249
  create_kwargs = {
248
250
  "model": self.llm.model,
@@ -634,17 +634,30 @@ class MainAgent(BaseAgent):
634
634
  ))
635
635
  all_tool_outputs = ""
636
636
  else:
637
- messages.append(Message(
638
- role="user",
639
- content=context.user_message or "请处理上述上下文。"
640
- ))
637
+ # [v1.16.12] 支持多模态消息(图片+文本)
638
+ user_images = context.metadata.get("user_images", [])
639
+ if user_images:
640
+ # OpenAI Vision 格式: [{type: "text"}, {type: "image_url"}]
641
+ multimodal_content = [{"type": "text", "text": context.user_message or "请描述这些图片。"}]
642
+ for img in user_images:
643
+ if img.get("url"):
644
+ multimodal_content.append({
645
+ "type": "image_url",
646
+ "image_url": {"url": img["url"]}
647
+ })
648
+ messages.append(Message(role="user", content=multimodal_content))
649
+ else:
650
+ messages.append(Message(
651
+ role="user",
652
+ content=context.user_message or "请处理上述上下文。"
653
+ ))
641
654
 
642
655
  # [v1.15.52] 保存完整的 LLM 输入消息(system prompt + user message + 工具结果回调)
643
656
  # 用于 Raw 查看器完整回溯 LLM 交互过程
644
657
  if self.memory:
645
658
  _input_parts = []
646
659
  for _msg in messages:
647
- _input_parts.append(f"=== {_msg.role.upper()} ===\n{_msg.content}")
660
+ _input_parts.append(f"=== {_msg.role.upper()} ===\n{_msg.get_text_content()}")
648
661
  _llm_input_text = "\n\n".join(_input_parts)
649
662
  self.memory.add_session(
650
663
  session_id=context.session_id,
package/core/llm.py CHANGED
@@ -17,7 +17,7 @@ import json
17
17
  import time
18
18
  import asyncio
19
19
  from typing import (
20
- Optional, Dict, Any, List, Generator, AsyncGenerator,
20
+ Optional, Dict, Any, List, Generator, AsyncGenerator, Union,
21
21
  )
22
22
  from dataclasses import dataclass, field
23
23
 
@@ -33,9 +33,16 @@ logger = get_logger("myagent.llm")
33
33
 
34
34
  @dataclass
35
35
  class Message:
36
- """聊天消息"""
36
+ """聊天消息
37
+
38
+ [v1.16.12] content 支持 str | list 类型:
39
+ - str: 纯文本消息 (兼容旧行为)
40
+ - list: 多模态内容,OpenAI Vision 格式:
41
+ [\n {"type": "text", "text": "描述这张图片"},
42
+ {"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}}\n ]
43
+ """
37
44
  role: str # system | user | assistant | tool
38
- content: str = ""
45
+ content: Any = "" # str | list (多模态内容)
39
46
  name: str = "" # 消息发送者标识
40
47
  tool_call_id: str = "" # 工具调用ID
41
48
  tool_calls: List[Dict] = field(default_factory=list)
@@ -67,6 +74,18 @@ class Message:
67
74
  result["tool_calls"] = formatted
68
75
  return result
69
76
 
77
+ def get_text_content(self) -> str:
78
+ """[v1.16.12] 提取纯文本内容(忽略图片等非文本部分)"""
79
+ if isinstance(self.content, str):
80
+ return self.content
81
+ if isinstance(self.content, list):
82
+ parts = []
83
+ for item in self.content:
84
+ if isinstance(item, dict) and item.get("type") == "text":
85
+ parts.append(item.get("text", ""))
86
+ return "\n".join(parts)
87
+ return str(self.content) if self.content else ""
88
+
70
89
  @classmethod
71
90
  def from_dict(cls, data: dict) -> "Message":
72
91
  return cls(
@@ -219,6 +238,41 @@ class LLMClient:
219
238
  # 所有使用 OpenAI 兼容接口的提供商
220
239
  _OPENAI_COMPATIBLE_PROVIDERS = ("openai", "custom", "modelscope", "deepseek", "moonshot", "qwen", "dashscope")
221
240
 
241
+ @staticmethod
242
+ def _convert_to_anthropic_content(content):
243
+ """将 OpenAI Vision 格式的 content 转换为 Anthropic 格式
244
+
245
+ OpenAI 格式: [{"type": "text", "text": "..."}, {"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}}]
246
+ Anthropic 格式: [{"type": "text", "text": "..."}, {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "..."}}]
247
+ """
248
+ if not isinstance(content, list):
249
+ return content
250
+
251
+ import re
252
+ anth_content = []
253
+ for item in content:
254
+ if isinstance(item, dict) and item.get("type") == "image_url":
255
+ url = item.get("image_url", {}).get("url", "")
256
+ # 解析 data URI: data:image/png;base64,xxxxx
257
+ match = re.match(r'^data:([^;]+);base64,(.+)$', url, re.DOTALL)
258
+ if match:
259
+ media_type = match.group(1)
260
+ b64_data = match.group(2)
261
+ anth_content.append({
262
+ "type": "image",
263
+ "source": {
264
+ "type": "base64",
265
+ "media_type": media_type,
266
+ "data": b64_data,
267
+ }
268
+ })
269
+ else:
270
+ # 非 data URI 格式(如 http URL),Anthropic 也支持但方式不同,暂保留原格式
271
+ anth_content.append(item)
272
+ else:
273
+ anth_content.append(item)
274
+ return anth_content
275
+
222
276
  def _ensure_client(self):
223
277
  """延迟初始化 LLM 客户端"""
224
278
  if self._client is not None:
@@ -533,7 +587,9 @@ class LLMClient:
533
587
  if m.role == "system":
534
588
  system_msg = m.content
535
589
  continue
536
- anth_messages.append({"role": m.role, "content": m.content})
590
+ # 转换 OpenAI Vision 格式为 Anthropic 格式
591
+ anth_content = self._convert_to_anthropic_content(m.content)
592
+ anth_messages.append({"role": m.role, "content": anth_content})
537
593
 
538
594
  create_kwargs = {
539
595
  "model": self.model,
@@ -650,6 +706,7 @@ class LLMClient:
650
706
  logger.error(f"流式调用不支持提供商: {self.provider}")
651
707
  except Exception as e:
652
708
  logger.error(f"流式 LLM 调用失败: {e}")
709
+ raise
653
710
 
654
711
  async def _stream_openai(self, kwargs: dict) -> AsyncGenerator[str, None]:
655
712
  """OpenAI / 兼容接口 (含 Zhipu) 流式调用
@@ -696,7 +753,9 @@ class LLMClient:
696
753
  if m.role == "system":
697
754
  system_msg = m.content
698
755
  continue
699
- anth_messages.append({"role": m.role, "content": m.content})
756
+ # 转换 OpenAI Vision 格式为 Anthropic 格式
757
+ anth_content = self._convert_to_anthropic_content(m.content)
758
+ anth_messages.append({"role": m.role, "content": anth_content})
700
759
 
701
760
  create_kwargs = {
702
761
  "model": self.model,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "myagent-ai",
3
- "version": "1.16.11",
3
+ "version": "1.16.13",
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
@@ -490,6 +490,112 @@ class ApiServer:
490
490
  self._task_persistence.initialize()
491
491
  return self._task_persistence
492
492
 
493
+ @staticmethod
494
+ def _extract_text_from_file(filename: str, mime_type: str, data: bytes) -> str:
495
+ """[v1.16.12] 从文件中提取文本内容,支持 txt/pdf/csv/md/json/py/js/html 等格式。
496
+
497
+ Args:
498
+ filename: 文件名
499
+ mime_type: MIME 类型
500
+ data: 文件二进制数据
501
+
502
+ Returns:
503
+ 提取的文本内容,最多 50000 字符
504
+ """
505
+ import io
506
+
507
+ fname_lower = filename.lower()
508
+
509
+ # 纯文本格式:直接读取
510
+ _text_exts = {'.txt', '.md', '.csv', '.tsv', '.log', '.yaml', '.yml',
511
+ '.toml', '.ini', '.cfg', '.conf', '.env', '.sh', '.bash',
512
+ '.zsh', '.fish', '.ps1', '.bat', '.cmd'}
513
+ _code_exts = {'.py', '.js', '.ts', '.jsx', '.tsx', '.java', '.c', '.cpp',
514
+ '.h', '.hpp', '.cs', '.go', '.rs', '.rb', '.php', '.swift',
515
+ '.kt', '.scala', '.r', '.sql', '.lua', '.vim', '.el'}
516
+ _markup_exts = {'.html', '.htm', '.xml', '.svg', '.css', '.scss', '.less'}
517
+ _data_exts = {'.json', '.jsonl'}
518
+
519
+ if (fname_lower.endswith(tuple(_text_exts | _code_exts | _markup_exts | _data_exts))):
520
+ # 尝试 UTF-8 解码,失败则尝试其他编码
521
+ for encoding in ('utf-8', 'gbk', 'gb2312', 'latin-1'):
522
+ try:
523
+ return data.decode(encoding)[:50000]
524
+ except (UnicodeDecodeError, LookupError):
525
+ continue
526
+ return f"[无法解码文件 {filename}]"
527
+
528
+ # PDF 格式
529
+ if fname_lower.endswith('.pdf') or 'pdf' in mime_type:
530
+ try:
531
+ import subprocess
532
+ # 使用 pdftotext 提取(系统工具,大多数 Linux 环境自带)
533
+ result = subprocess.run(
534
+ ['pdftotext', '-', '-'],
535
+ input=data, capture_output=True, text=True, timeout=30,
536
+ )
537
+ if result.returncode == 0 and result.stdout.strip():
538
+ return result.stdout.strip()[:50000]
539
+ except Exception:
540
+ pass
541
+ # 尝试 PyPDF2
542
+ try:
543
+ import io as _io
544
+ from PyPDF2 import PdfReader
545
+ reader = PdfReader(_io.BytesIO(data))
546
+ texts = []
547
+ for page in reader.pages:
548
+ text = page.extract_text()
549
+ if text:
550
+ texts.append(text)
551
+ if texts:
552
+ return "\n".join(texts)[:50000]
553
+ except ImportError:
554
+ pass
555
+ except Exception:
556
+ pass
557
+ return f"[无法提取 PDF 文件 {filename} 的文本内容,请安装 poppler-utils (apt install poppler-utils)]"
558
+
559
+ # Excel 格式
560
+ if fname_lower.endswith(('.xlsx', '.xls')) or 'excel' in mime_type or 'spreadsheet' in mime_type:
561
+ try:
562
+ from openpyxl import load_workbook
563
+ wb = load_workbook(io.BytesIO(data), read_only=True, data_only=True)
564
+ texts = []
565
+ for ws in wb.worksheets:
566
+ rows = []
567
+ for row in ws.iter_rows(values_only=True):
568
+ row_text = "\t".join(str(c) if c is not None else "" for c in row)
569
+ rows.append(row_text)
570
+ if rows:
571
+ texts.append(f"[Sheet: {ws.title}]\n" + "\n".join(rows[:500]))
572
+ wb.close()
573
+ if texts:
574
+ return "\n".join(texts)[:50000]
575
+ except ImportError:
576
+ return f"[无法读取 Excel 文件,需安装 openpyxl: pip install openpyxl]"
577
+ except Exception as e:
578
+ return f"[Excel 文件读取失败: {e}]"
579
+
580
+ # Word 文档
581
+ if fname_lower.endswith('.docx') or 'wordprocessingml' in mime_type:
582
+ try:
583
+ from docx import Document
584
+ doc = Document(io.BytesIO(data))
585
+ text = "\n".join(p.text for p in doc.paragraphs if p.text)
586
+ return text[:50000] if text else "[Word 文档内容为空]"
587
+ except ImportError:
588
+ return f"[无法读取 Word 文件,需安装 python-docx: pip install python-docx]"
589
+ except Exception as e:
590
+ return f"[Word 文件读取失败: {e}]"
591
+
592
+ # 图片格式 — 返回描述提示(图片由 Vision API 直接处理)
593
+ _image_exts = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.ico'}
594
+ if fname_lower.endswith(_image_exts) or 'image/' in mime_type:
595
+ return f"[图片文件: {filename}]"
596
+
597
+ return f"[不支持的文件格式: {filename} ({mime_type})]"
598
+
493
599
  # --- Execution Progress ---
494
600
  async def handle_execution_progress(self, request):
495
601
  """GET /api/execution/progress - 返回当前执行进度"""
@@ -620,7 +726,15 @@ class ApiServer:
620
726
  print(f"[STREAM_MESSAGE] 函数被调用, PYTHONIOENCODING={getattr(sys, 'stdout', None)}", flush=True, file=sys.stdout)
621
727
 
622
728
  message = data.get("message", "").strip()
623
- if not message:
729
+ # [v1.16.12] 支持图片附件
730
+ user_images = data.get("images", []) # [{"type": "image/png", "data": "base64..."}, ...]
731
+ # 支持文件附件(文档等,提取文本后作为上下文)
732
+ user_files = data.get("files", []) # [{"name": "xxx.pdf", "type": "application/pdf", "data": "base64..."}, ...]
733
+
734
+ # 如果消息为空但有图片,设置默认提示
735
+ if not message and user_images:
736
+ message = "请查看这些图片"
737
+ if not message and not user_images and not user_files:
624
738
  return web.Response(text="data: " + json.dumps({"error": "message is required"}) + "\n\n", content_type="text/event-stream")
625
739
 
626
740
  agent_path = data.get("agent_path", data.get("agent_name", "default")) or "default"
@@ -738,12 +852,14 @@ class ApiServer:
738
852
  agent_path=agent_path, agent_system_prompt=agent_system_prompt,
739
853
  chat_mode=chat_mode, stream_response=proxy,
740
854
  voice_text=voice_text,
855
+ user_images=user_images, user_files=user_files,
741
856
  )
742
857
  elif self.core.main_agent and self.core.llm:
743
858
  full_response = await self._stream_process_message(
744
859
  clean_message, session_id, proxy,
745
860
  agent_path=agent_path, agent_system_prompt=agent_system_prompt,
746
861
  chat_mode=chat_mode, voice_text=voice_text,
862
+ user_images=user_images, user_files=user_files,
747
863
  )
748
864
  else:
749
865
  full_response = await self.core.process_message(clean_message, session_id)
@@ -3585,7 +3701,7 @@ class ApiServer:
3585
3701
  async def _try_model_chain_stream(self, model_chain, message, session_id,
3586
3702
  agent_path=None, agent_system_prompt=None,
3587
3703
  chat_mode="", stream_response=None,
3588
- voice_text=""):
3704
+ voice_text="", user_images=None, user_files=None):
3589
3705
  """流式版本的模型链调用,逐token输出到SSE
3590
3706
 
3591
3707
  使用 asyncio.Lock 保护共享的 self.core.llm,防止并发请求互相干扰。
@@ -3601,12 +3717,13 @@ class ApiServer:
3601
3717
  agent_path=agent_path, agent_system_prompt=agent_system_prompt,
3602
3718
  chat_mode=chat_mode, stream_response=stream_response,
3603
3719
  voice_text=voice_text,
3720
+ user_images=user_images, user_files=user_files,
3604
3721
  )
3605
3722
 
3606
3723
  async def _try_model_chain_stream_inner(self, model_chain, message, session_id,
3607
3724
  agent_path=None, agent_system_prompt=None,
3608
3725
  chat_mode="", stream_response=None,
3609
- voice_text=""):
3726
+ voice_text="", user_images=None, user_files=None):
3610
3727
  """_try_model_chain_stream 的实际执行体(已在 _model_chain_lock 保护下)"""
3611
3728
  llm = self.core.llm
3612
3729
  full_text = ""
@@ -3638,6 +3755,7 @@ class ApiServer:
3638
3755
  message, session_id, stream_response,
3639
3756
  agent_path=agent_path, agent_system_prompt=agent_system_prompt,
3640
3757
  chat_mode=chat_mode, voice_text=voice_text,
3758
+ user_images=user_images, user_files=user_files,
3641
3759
  )
3642
3760
  if result and not result.startswith("⚠️") and not result.startswith("❌"):
3643
3761
  return result
@@ -3674,7 +3792,7 @@ class ApiServer:
3674
3792
 
3675
3793
  async def _stream_process_message(self, user_message, session_id, stream_response,
3676
3794
  agent_path=None, agent_system_prompt=None, chat_mode="",
3677
- voice_text=""):
3795
+ voice_text="", user_images=None, user_files=None):
3678
3796
  """使用流式LLM调用处理消息,支持完整的agent循环(工具调用/操作执行)+ 实时流式输出
3679
3797
 
3680
3798
  核心改进:
@@ -3682,8 +3800,7 @@ class ApiServer:
3682
3800
  - 使用 frequency_penalty 减少大模型重复输出
3683
3801
  - 最终保存时使用累积文本而非 final_response,确保完整内容不丢失
3684
3802
 
3685
- 实现与 MainAgent._process_inner() 相同的计划-执行-反思循环,
3686
- 但将 LLM 的文本响应逐 token 流式推送到 SSE。
3803
+ [v1.16.12] 新增 user_images/user_files 参数支持多模态消息
3687
3804
  """
3688
3805
  logger.info(f"[{session_id}] _stream_process_message 开始处理,chat_mode={chat_mode}")
3689
3806
  if not self.core.main_agent or not self.core.llm:
@@ -3702,6 +3819,47 @@ class ApiServer:
3702
3819
  context.metadata["chat_mode"] = chat_mode
3703
3820
  context.metadata["user_voice_text"] = voice_text # 语音输入原始文本(用于 usersays_correct)
3704
3821
 
3822
+ # [v1.16.12] 处理用户图片附件 — 转换为 data URI 传给 LLM Vision API
3823
+ if user_images:
3824
+ _processed_images = []
3825
+ for img in user_images:
3826
+ mime = img.get("type", "image/png")
3827
+ b64 = img.get("data", "")
3828
+ if b64:
3829
+ _processed_images.append({
3830
+ "url": f"data:{mime};base64,{b64}",
3831
+ "type": mime,
3832
+ "name": img.get("name", ""),
3833
+ })
3834
+ if _processed_images:
3835
+ context.metadata["user_images"] = _processed_images
3836
+ logger.info(f"[{session_id}] 用户发送了 {len(_processed_images)} 张图片")
3837
+
3838
+ # [v1.16.12] 处理用户文件附件 — 提取文本内容附加到消息中
3839
+ if user_files:
3840
+ _file_texts = []
3841
+ for f in user_files:
3842
+ fname = f.get("name", "unknown")
3843
+ ftype = f.get("type", "")
3844
+ fdata_b64 = f.get("data", "")
3845
+ if not fdata_b64:
3846
+ continue
3847
+ try:
3848
+ import base64 as _b64mod
3849
+ fbytes = _b64mod.b64decode(fdata_b64)
3850
+ _text = self._extract_text_from_file(fname, ftype, fbytes)
3851
+ if _text:
3852
+ _file_texts.append(f"--- 文件: {fname} ---\n{_text}")
3853
+ except Exception as _fe:
3854
+ logger.warning(f"[{session_id}] 文件 {fname} 提取失败: {_fe}")
3855
+ if _file_texts:
3856
+ file_context = "\n\n".join(_file_texts)
3857
+ context.metadata["user_file_texts"] = file_context
3858
+ # 将文件内容附加到 user_message 中
3859
+ user_message = f"{user_message}\n\n[附件内容]\n{file_context}" if user_message else f"[附件内容]\n{file_context}"
3860
+ context.user_message = user_message
3861
+ logger.info(f"[{session_id}] 用户发送了 {len(user_files)} 个文件,提取文本 {len(file_context)} 字符")
3862
+
3705
3863
  # ── 根据 Agent 配置设置执行引擎参数(execution_mode 等)──
3706
3864
  agent_cfg_for_exec = self._read_agent_config(agent_path)
3707
3865
  _original_exec_mode = None
@@ -570,7 +570,7 @@ input,textarea,select{font:inherit}
570
570
  .text-input-area {
571
571
  display: flex;
572
572
  align-items: flex-end;
573
- gap: 10px;
573
+ gap: 8px;
574
574
  width: 100%;
575
575
  flex: 1;
576
576
  }
@@ -581,6 +581,82 @@ input,textarea,select{font:inherit}
581
581
  }
582
582
  .input-box textarea::placeholder{color:var(--text3)}
583
583
 
584
+ /* [v1.16.12] 附件按钮 */
585
+ .attach-buttons {
586
+ display: flex;
587
+ flex-direction: column;
588
+ gap: 4px;
589
+ flex-shrink: 0;
590
+ padding-bottom: 2px;
591
+ }
592
+ .attach-btn {
593
+ width:30px;height:30px;border:none;background:transparent;color:var(--text3);
594
+ display:grid;place-items:center;border-radius:var(--radius-xs);
595
+ cursor:pointer;transition:var(--transition);
596
+ }
597
+ .attach-btn:hover{color:var(--accent);background:var(--accent-light)}
598
+ .attach-btn svg{width:18px;height:18px}
599
+
600
+ /* [v1.16.12] 拖拽高亮 */
601
+ .input-box.drag-over{border-color:var(--accent) !important;box-shadow:0 0 0 2px rgba(79,70,229,.2)}
602
+
603
+ /* [v1.16.12] 附件预览 */
604
+ .attachment-preview {
605
+ display:flex;flex-wrap:wrap;gap:8px;padding:8px 0;
606
+ align-items:center;
607
+ }
608
+ .attachment-preview:empty{display:none}
609
+ .attachment-thumb {
610
+ position:relative;border-radius:var(--radius-sm);overflow:hidden;
611
+ border:1px solid var(--bg4);cursor:pointer;transition:var(--transition);
612
+ }
613
+ .attachment-thumb:hover{border-color:var(--accent)}
614
+ .attachment-thumb-image {
615
+ width:80px;height:80px;
616
+ }
617
+ .attachment-thumb-image img {
618
+ width:100%;height:100%;object-fit:cover;display:block;
619
+ }
620
+ .attachment-thumb-file {
621
+ display:flex;flex-direction:column;align-items:center;justify-content:center;
622
+ width:80px;height:80px;background:var(--bg3);padding:6px;gap:4px;
623
+ }
624
+ .attachment-file-icon{font-size:22px;line-height:1}
625
+ .attachment-file-name{
626
+ font-size:10px;color:var(--text2);text-align:center;
627
+ max-width:70px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
628
+ }
629
+ .attachment-remove {
630
+ position:absolute;top:-2px;right:-2px;
631
+ width:18px;height:18px;border-radius:50%;
632
+ background:var(--danger);color:#fff;border:none;
633
+ font-size:13px;line-height:1;cursor:pointer;
634
+ display:none;align-items:center;justify-content:center;
635
+ padding:0;
636
+ }
637
+ .attachment-thumb:hover .attachment-remove{display:flex}
638
+
639
+ /* [v1.16.12] 消息气泡中的附件 */
640
+ .msg-attachments {
641
+ display:flex;flex-wrap:wrap;gap:8px;margin-bottom:8px;
642
+ }
643
+ .msg-image-wrapper {
644
+ max-width:300px;border-radius:var(--radius-sm);overflow:hidden;
645
+ border:1px solid var(--bg4);cursor:pointer;
646
+ }
647
+ .msg-image {
648
+ width:100%;height:auto;display:block;max-height:300px;object-fit:cover;
649
+ }
650
+ .msg-image-wrapper:hover .msg-image{max-height:none}
651
+ .msg-file-item {
652
+ display:flex;align-items:center;gap:8px;padding:6px 10px;
653
+ background:var(--bg3);border-radius:var(--radius-sm);
654
+ font-size:13px;color:var(--text2);max-width:240px;
655
+ }
656
+ .msg-file-icon{font-size:18px;flex-shrink:0}
657
+ .msg-file-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
658
+ .msg-file-size{font-size:11px;color:var(--text3);flex-shrink:0}
659
+
584
660
  .send-btn{
585
661
  width:36px;height:36px;border-radius:var(--radius-sm);
586
662
  background:var(--accent);color:#fff;
@@ -157,9 +157,18 @@
157
157
  <span class="lock-text" id="lockText"></span>
158
158
  </div>
159
159
  </div>
160
- <div class="input-box" id="inputBox">
160
+ <div class="input-box" id="inputBox" ondragover="event.preventDefault();this.classList.add('drag-over')" ondragleave="this.classList.remove('drag-over')" ondrop="handleDropEvent(event);this.classList.remove('drag-over')">
161
161
  <div class="text-input-area" id="textInputArea">
162
- <textarea id="userInput" placeholder="输入消息... (Enter 发送, Shift+Enter 换行)" rows="1" onkeydown="handleKeyDown(event)" oninput="autoResize(this)"></textarea>
162
+ <textarea id="userInput" placeholder="输入消息... (Enter 发送, Shift+Enter 换行, 可粘贴图片)" rows="1" onkeydown="handleKeyDown(event)" oninput="autoResize(this);updateSendBtnState()" onpaste="handlePasteEvent(event)"></textarea>
163
+ <!-- [v1.16.12] 附件按钮 -->
164
+ <div class="attach-buttons">
165
+ <button class="attach-btn" id="attachImageBtn" onclick="document.getElementById('imageFileInput').click()" title="上传图片">
166
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
167
+ </button>
168
+ <button class="attach-btn" id="attachFileBtn" onclick="document.getElementById('docFileInput').click()" title="上传文件">
169
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
170
+ </button>
171
+ </div>
163
172
  <button class="send-btn" id="sendBtn" onclick="sendMessage()" disabled>
164
173
  <svg viewBox="0 0 24 24" fill="currentColor"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
165
174
  </button>
@@ -328,6 +328,8 @@ async function initChat() {
328
328
  initTheme();
329
329
  // Restore persisted UI state
330
330
  StatePersistence.restoreUIState();
331
+ // [v1.16.12] 初始化附件上传 UI
332
+ initAttachmentUI();
331
333
 
332
334
  // URL 参数处理: ?agent=xxx&mode=exec&session=xxx&popout=1
333
335
  const urlParams = new URLSearchParams(window.location.search);
@@ -2498,6 +2500,31 @@ function _renderMessagesInner() {
2498
2500
 
2499
2501
  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;');
2500
2502
  const content = renderMarkdown(msg.content);
2503
+ // [v1.16.12] 渲染图片和文件附件
2504
+ const attachmentHtml = (() => {
2505
+ if (!isUser) return '';
2506
+ let parts = [];
2507
+ // 图片
2508
+ if (msg.images && msg.images.length > 0) {
2509
+ for (const img of msg.images) {
2510
+ const dataUrl = 'data:' + (img.type || 'image/png') + ';base64,' + (img.data || '');
2511
+ parts.push('<div class="msg-image-wrapper"><img src="' + dataUrl + '" class="msg-image" onclick="window.open(this.src)" loading="lazy" alt="' + escapeHtml(img.name || 'image') + '" /></div>');
2512
+ }
2513
+ }
2514
+ // 文件
2515
+ if (msg.files && msg.files.length > 0) {
2516
+ for (const f of msg.files) {
2517
+ const sizeStr = f.size ? formatFileSize(f.size) : '';
2518
+ const icon = _getFileIcon(f.name || f.type || '');
2519
+ parts.push('<div class="msg-file-item">' +
2520
+ '<span class="msg-file-icon">' + icon + '</span>' +
2521
+ '<span class="msg-file-name" title="' + escapeHtml(f.name) + '">' + escapeHtml(f.name) + '</span>' +
2522
+ (sizeStr ? '<span class="msg-file-size">' + sizeStr + '</span>' : '') +
2523
+ '</div>');
2524
+ }
2525
+ }
2526
+ return parts.length > 0 ? '<div class="msg-attachments">' + parts.join('') + '</div>' : '';
2527
+ })();
2501
2528
  const thoughtHtml = msg.thought ? (() => {
2502
2529
  const isStreaming = !!msg.streaming;
2503
2530
  return `<details class="thought-block ${isStreaming ? 'streaming' : ''}" ${isStreaming ? 'open' : ''}>
@@ -2603,6 +2630,7 @@ function _renderMessagesInner() {
2603
2630
  ${finishReasonHtml}
2604
2631
  ${timelineHtml}
2605
2632
  ${singleBubbleHtml}
2633
+ ${attachmentHtml}
2606
2634
  ${streamingIndicator}
2607
2635
  ${execEventsHtml}
2608
2636
  ${msg.time ? `<div class="message-time">${formatTime(msg.time)}</div>` : ''}
@@ -2719,6 +2747,275 @@ function renderMarkdown(text) {
2719
2747
  }
2720
2748
 
2721
2749
  // 高效的 HTML 转义(不创建 DOM 元素,避免大文本时性能问题)
2750
+ // ── [v1.16.12] 文件上传辅助函数 ──
2751
+ function formatFileSize(bytes) {
2752
+ if (bytes < 1024) return bytes + ' B';
2753
+ if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
2754
+ return (bytes / 1048576).toFixed(1) + ' MB';
2755
+ }
2756
+
2757
+ function _getFileIcon(name) {
2758
+ var ext = (name || '').split('.').pop().toLowerCase();
2759
+ var map = {
2760
+ 'pdf': '📄', 'doc': '📝', 'docx': '📝', 'xls': '📊', 'xlsx': '📊',
2761
+ 'ppt': '📽️', 'pptx': '📽️', 'csv': '📋', 'json': '🔧', 'xml': '🔧',
2762
+ 'html': '🌐', 'css': '🎨', 'js': '⚡', 'ts': '⚡', 'py': '🐍',
2763
+ 'java': '☕', 'c': '⚙️', 'cpp': '⚙️', 'go': '🔵', 'rs': '🦀',
2764
+ 'md': '📝', 'txt': '📃', 'log': '📃', 'yaml': '⚙️', 'yml': '⚙️',
2765
+ 'zip': '📦', 'rar': '📦', '7z': '📦', 'tar': '📦', 'gz': '📦',
2766
+ 'png': '🖼️', 'jpg': '🖼️', 'jpeg': '🖼️', 'gif': '🖼️', 'webp': '🖼️',
2767
+ 'svg': '🖼️', 'mp3': '🎵', 'mp4': '🎬', 'wav': '🎵',
2768
+ };
2769
+ return map[ext] || '📎';
2770
+ }
2771
+
2772
+ // ── 附件上传系统 ──
2773
+ var _attachState = { images: [], files: [] };
2774
+
2775
+ function initAttachmentUI() {
2776
+ window._pendingImages = [];
2777
+ window._pendingFiles = [];
2778
+
2779
+ // [v1.16.13] 图片压缩配置
2780
+ window._IMAGE_COMPRESS = {
2781
+ maxWidth: 2048, // 最大宽度
2782
+ maxHeight: 2048, // 最大高度
2783
+ quality: 0.85, // JPEG 压缩质量
2784
+ maxSizeBytes: 4 * 1024 * 1024, // 单张图片最大 4MB (base64后约 5.3MB)
2785
+ skipCompression: false, // 跳过压缩(如果图片已经足够小)
2786
+ };
2787
+
2788
+ // 创建隐藏的文件输入
2789
+ if (!document.getElementById('imageFileInput')) {
2790
+ var imgInput = document.createElement('input');
2791
+ imgInput.type = 'file';
2792
+ imgInput.id = 'imageFileInput';
2793
+ imgInput.accept = 'image/*';
2794
+ imgInput.multiple = true;
2795
+ imgInput.style.display = 'none';
2796
+ imgInput.onchange = function() { handleFileSelect(this, 'image'); this.value = ''; };
2797
+ document.body.appendChild(imgInput);
2798
+ }
2799
+ if (!document.getElementById('docFileInput')) {
2800
+ var docInput = document.createElement('input');
2801
+ docInput.type = 'file';
2802
+ docInput.id = 'docFileInput';
2803
+ docInput.accept = '.txt,.md,.csv,.json,.py,.js,.ts,.java,.c,.cpp,.go,.rs,.rb,.php,.html,.css,.xml,.yaml,.yml,.toml,.ini,.sh,.log,.pdf,.docx,.xlsx,.pptx';
2804
+ docInput.multiple = true;
2805
+ docInput.style.display = 'none';
2806
+ docInput.onchange = function() { handleFileSelect(this, 'file'); this.value = ''; };
2807
+ document.body.appendChild(docInput);
2808
+ }
2809
+ }
2810
+
2811
+ // [v1.16.13] 图片压缩函数 — 缩放尺寸 + JPEG 压缩,返回 Promise<{base64, width, height}>
2812
+ function compressImage(file) {
2813
+ return new Promise(function(resolve, reject) {
2814
+ var cfg = window._IMAGE_COMPRESS || {};
2815
+ var reader = new FileReader();
2816
+ reader.onerror = function() { reject(new Error('读取文件失败')); };
2817
+ reader.onload = function(e) {
2818
+ var img = new Image();
2819
+ img.onerror = function() { reject(new Error('图片加载失败')); };
2820
+ img.onload = function() {
2821
+ var w = img.naturalWidth;
2822
+ var h = img.naturalHeight;
2823
+ // 如果图片已足够小且体积未超限,直接返回原始数据
2824
+ var rawBase64 = e.target.result.split(',')[1];
2825
+ if (cfg.skipCompression && file.size <= (cfg.maxSizeBytes || 4194304) && w <= (cfg.maxWidth || 2048) && h <= (cfg.maxHeight || 2048)) {
2826
+ resolve({ base64: rawBase64, width: w, height: h, type: file.type });
2827
+ return;
2828
+ }
2829
+ // 计算缩放比例
2830
+ var maxW = cfg.maxWidth || 2048;
2831
+ var maxH = cfg.maxHeight || 2048;
2832
+ var ratio = Math.min(maxW / w, maxH / h, 1);
2833
+ var newW = Math.round(w * ratio);
2834
+ var newH = Math.round(h * ratio);
2835
+ // Canvas 绘制并压缩
2836
+ var canvas = document.createElement('canvas');
2837
+ canvas.width = newW;
2838
+ canvas.height = newH;
2839
+ var ctx = canvas.getContext('2d');
2840
+ ctx.drawImage(img, 0, 0, newW, newH);
2841
+ // 如果是 PNG 透明图且不大,保留 PNG;否则转 JPEG
2842
+ var useJpeg = file.type !== 'image/png' || file.size > 500000;
2843
+ var mimeType = useJpeg ? 'image/jpeg' : file.type;
2844
+ var quality = useJpeg ? (cfg.quality || 0.85) : undefined;
2845
+ var dataUrl = canvas.toDataURL(mimeType, quality);
2846
+ var b64 = dataUrl.split(',')[1];
2847
+ // 如果压缩后反而更大(罕见),用原始数据
2848
+ if (b64.length > rawBase64.length) {
2849
+ b64 = rawBase64;
2850
+ mimeType = file.type;
2851
+ }
2852
+ resolve({ base64: b64, width: newW, height: newH, type: mimeType });
2853
+ };
2854
+ img.src = e.target.result;
2855
+ };
2856
+ reader.readAsDataURL(file);
2857
+ });
2858
+ }
2859
+
2860
+ function handleFileSelect(input, type) {
2861
+ var files = input.files;
2862
+ if (!files || files.length === 0) return;
2863
+
2864
+ for (var i = 0; i < files.length; i++) {
2865
+ (function(file) {
2866
+ if (type === 'image' && file.type.startsWith('image/')) {
2867
+ // [v1.16.13] 图片文件使用压缩
2868
+ compressImage(file).then(function(result) {
2869
+ window._pendingImages.push({
2870
+ type: result.type,
2871
+ data: result.base64,
2872
+ name: file.name,
2873
+ size: file.size,
2874
+ });
2875
+ renderAttachmentPreview();
2876
+ updateSendBtnState();
2877
+ }).catch(function(err) {
2878
+ console.error('图片压缩失败:', err);
2879
+ });
2880
+ } else {
2881
+ var reader = new FileReader();
2882
+ reader.onload = function(e) {
2883
+ var base64 = e.target.result.split(',')[1];
2884
+ window._pendingFiles.push({
2885
+ type: file.type,
2886
+ data: base64,
2887
+ name: file.name,
2888
+ size: file.size,
2889
+ });
2890
+ renderAttachmentPreview();
2891
+ updateSendBtnState();
2892
+ };
2893
+ reader.readAsDataURL(file);
2894
+ }
2895
+ })(files[i]);
2896
+ }
2897
+ }
2898
+
2899
+ function handlePasteEvent(e) {
2900
+ var items = e.clipboardData && e.clipboardData.items;
2901
+ if (!items) return;
2902
+ var hasImage = false;
2903
+ for (var i = 0; i < items.length; i++) {
2904
+ if (items[i].type.startsWith('image/')) {
2905
+ e.preventDefault();
2906
+ hasImage = true;
2907
+ var file = items[i].getAsFile();
2908
+ if (file) {
2909
+ (function(f) {
2910
+ // [v1.16.13] 粘贴图片使用压缩
2911
+ compressImage(f).then(function(result) {
2912
+ window._pendingImages.push({
2913
+ type: result.type,
2914
+ data: result.base64,
2915
+ name: 'paste-' + Date.now() + '.png',
2916
+ size: f.size,
2917
+ });
2918
+ renderAttachmentPreview();
2919
+ updateSendBtnState();
2920
+ }).catch(function(err) {
2921
+ console.error('粘贴图片压缩失败:', err);
2922
+ });
2923
+ })(file);
2924
+ }
2925
+ }
2926
+ }
2927
+ }
2928
+
2929
+ function handleDropEvent(e) {
2930
+ e.preventDefault();
2931
+ e.stopPropagation();
2932
+ var dt = e.dataTransfer;
2933
+ if (!dt || !dt.files || dt.files.length === 0) return;
2934
+ for (var i = 0; i < dt.files.length; i++) {
2935
+ (function(file) {
2936
+ if (file.type.startsWith('image/')) {
2937
+ // [v1.16.13] 拖放图片使用压缩
2938
+ compressImage(file).then(function(result) {
2939
+ window._pendingImages.push({ type: result.type, data: result.base64, name: file.name, size: file.size });
2940
+ renderAttachmentPreview();
2941
+ updateSendBtnState();
2942
+ }).catch(function(err) {
2943
+ console.error('拖放图片压缩失败:', err);
2944
+ });
2945
+ } else {
2946
+ var reader = new FileReader();
2947
+ reader.onload = function(ev) {
2948
+ var base64 = ev.target.result.split(',')[1];
2949
+ window._pendingFiles.push({ type: file.type, data: base64, name: file.name, size: file.size });
2950
+ renderAttachmentPreview();
2951
+ updateSendBtnState();
2952
+ };
2953
+ reader.readAsDataURL(file);
2954
+ }
2955
+ })(dt.files[i]);
2956
+ }
2957
+ }
2958
+
2959
+ function renderAttachmentPreview() {
2960
+ var container = document.getElementById('attachmentPreview');
2961
+ if (!container) {
2962
+ // 创建预览容器(在 input-box 之前)
2963
+ var inputBox = document.getElementById('inputBox');
2964
+ if (!inputBox) return;
2965
+ container = document.createElement('div');
2966
+ container.id = 'attachmentPreview';
2967
+ container.className = 'attachment-preview';
2968
+ inputBox.parentNode.insertBefore(container, inputBox);
2969
+ }
2970
+
2971
+ var html = '';
2972
+ // 图片预览
2973
+ var imgs = window._pendingImages || [];
2974
+ for (var i = 0; i < imgs.length; i++) {
2975
+ var img = imgs[i];
2976
+ html += '<div class="attachment-thumb attachment-thumb-image" onclick="removeAttachment(\'image\',' + i + ')">' +
2977
+ '<img src="data:' + (img.type || 'image/png') + ';base64,' + img.data + '" />' +
2978
+ '<button class="attachment-remove" onclick="event.stopPropagation();removeAttachment(\'image\',' + i + ')">×</button>' +
2979
+ '</div>';
2980
+ }
2981
+ // 文件预览
2982
+ var files = window._pendingFiles || [];
2983
+ for (var j = 0; j < files.length; j++) {
2984
+ var f = files[j];
2985
+ html += '<div class="attachment-thumb attachment-thumb-file" onclick="removeAttachment(\'file\',' + j + ')">' +
2986
+ '<span class="attachment-file-icon">' + _getFileIcon(f.name) + '</span>' +
2987
+ '<span class="attachment-file-name">' + escapeHtml(f.name) + '</span>' +
2988
+ '<button class="attachment-remove" onclick="event.stopPropagation();removeAttachment(\'file\',' + j + ')">×</button>' +
2989
+ '</div>';
2990
+ }
2991
+ container.innerHTML = html;
2992
+ container.style.display = html ? 'flex' : 'none';
2993
+ }
2994
+
2995
+ function removeAttachment(type, index) {
2996
+ if (type === 'image') {
2997
+ (window._pendingImages || []).splice(index, 1);
2998
+ } else {
2999
+ (window._pendingFiles || []).splice(index, 1);
3000
+ }
3001
+ renderAttachmentPreview();
3002
+ updateSendBtnState();
3003
+ }
3004
+
3005
+ function clearAttachmentPreview() {
3006
+ var container = document.getElementById('attachmentPreview');
3007
+ if (container) { container.innerHTML = ''; container.style.display = 'none'; }
3008
+ }
3009
+
3010
+ function updateSendBtnState() {
3011
+ var input = document.getElementById('userInput');
3012
+ var btn = document.getElementById('sendBtn');
3013
+ if (!btn) return;
3014
+ var hasContent = input && input.value.trim().length > 0;
3015
+ var hasAttach = (window._pendingImages || []).length > 0 || (window._pendingFiles || []).length > 0;
3016
+ btn.disabled = !hasContent && !hasAttach;
3017
+ }
3018
+
2722
3019
  function escapeHtml(text) {
2723
3020
  if (!text) return '';
2724
3021
  return text
@@ -1354,7 +1354,29 @@ async function sendMessage(opts) {
1354
1354
  }
1355
1355
 
1356
1356
  // Add user message
1357
- state.messages.push({ role: 'user', content: text, time: new Date().toISOString(), _voiceText: voiceText });
1357
+ // [v1.16.12] 支持图片和文件附件
1358
+ var _pendingImages = window._pendingImages || []; // [{type, data, name}]
1359
+ var _pendingFiles = window._pendingFiles || []; // [{type, data, name}]
1360
+ var _msgImages = _pendingImages.slice();
1361
+ var _msgFiles = _pendingFiles.slice();
1362
+
1363
+ var userMsgObj = {
1364
+ role: 'user',
1365
+ content: text,
1366
+ time: new Date().toISOString(),
1367
+ _voiceText: voiceText
1368
+ };
1369
+ if (_msgImages.length > 0) {
1370
+ userMsgObj.images = _msgImages.map(function(img) {
1371
+ return { type: img.type, data: img.data, name: img.name };
1372
+ });
1373
+ }
1374
+ if (_msgFiles.length > 0) {
1375
+ userMsgObj.files = _msgFiles.map(function(f) {
1376
+ return { type: f.type, name: f.name, size: f.size };
1377
+ });
1378
+ }
1379
+ state.messages.push(userMsgObj);
1358
1380
  renderMessages();
1359
1381
 
1360
1382
  // Clear input
@@ -1362,6 +1384,12 @@ async function sendMessage(opts) {
1362
1384
  input.style.height = 'auto';
1363
1385
  document.getElementById('sendBtn').disabled = true;
1364
1386
  clearDraft();
1387
+ // [v1.16.12] 清除附件
1388
+ window._pendingImages = [];
1389
+ window._pendingFiles = [];
1390
+ var _attachPreview = document.getElementById('attachmentPreview');
1391
+ if (_attachPreview) _attachPreview.innerHTML = '';
1392
+ if (typeof clearAttachmentPreview === 'function') clearAttachmentPreview();
1365
1393
 
1366
1394
  // 用户发消息后,强制滚到底部
1367
1395
  _userScrollLocked = false;
@@ -1394,6 +1422,8 @@ async function sendMessage(opts) {
1394
1422
  mode: state.chatMode,
1395
1423
  escalated: state.escalated,
1396
1424
  voice_text: voiceText, // 语音转文字原始文本(用于后端 usersays_correct)
1425
+ images: _msgImages.map(function(img) { return { type: img.type, data: img.data, name: img.name }; }),
1426
+ files: _msgFiles.map(function(f) { return { type: f.type, data: f.data, name: f.name }; }),
1397
1427
  }),
1398
1428
  signal: state.abortController.signal,
1399
1429
  });