myagent-ai 1.16.11 → 1.16.12

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.
@@ -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(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "myagent-ai",
3
- "version": "1.16.11",
3
+ "version": "1.16.12",
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"
@@ -744,6 +858,7 @@ class ApiServer:
744
858
  clean_message, session_id, proxy,
745
859
  agent_path=agent_path, agent_system_prompt=agent_system_prompt,
746
860
  chat_mode=chat_mode, voice_text=voice_text,
861
+ user_images=user_images, user_files=user_files,
747
862
  )
748
863
  else:
749
864
  full_response = await self.core.process_message(clean_message, session_id)
@@ -3674,7 +3789,7 @@ class ApiServer:
3674
3789
 
3675
3790
  async def _stream_process_message(self, user_message, session_id, stream_response,
3676
3791
  agent_path=None, agent_system_prompt=None, chat_mode="",
3677
- voice_text=""):
3792
+ voice_text="", user_images=None, user_files=None):
3678
3793
  """使用流式LLM调用处理消息,支持完整的agent循环(工具调用/操作执行)+ 实时流式输出
3679
3794
 
3680
3795
  核心改进:
@@ -3682,8 +3797,7 @@ class ApiServer:
3682
3797
  - 使用 frequency_penalty 减少大模型重复输出
3683
3798
  - 最终保存时使用累积文本而非 final_response,确保完整内容不丢失
3684
3799
 
3685
- 实现与 MainAgent._process_inner() 相同的计划-执行-反思循环,
3686
- 但将 LLM 的文本响应逐 token 流式推送到 SSE。
3800
+ [v1.16.12] 新增 user_images/user_files 参数支持多模态消息
3687
3801
  """
3688
3802
  logger.info(f"[{session_id}] _stream_process_message 开始处理,chat_mode={chat_mode}")
3689
3803
  if not self.core.main_agent or not self.core.llm:
@@ -3702,6 +3816,47 @@ class ApiServer:
3702
3816
  context.metadata["chat_mode"] = chat_mode
3703
3817
  context.metadata["user_voice_text"] = voice_text # 语音输入原始文本(用于 usersays_correct)
3704
3818
 
3819
+ # [v1.16.12] 处理用户图片附件 — 转换为 data URI 传给 LLM Vision API
3820
+ if user_images:
3821
+ _processed_images = []
3822
+ for img in user_images:
3823
+ mime = img.get("type", "image/png")
3824
+ b64 = img.get("data", "")
3825
+ if b64:
3826
+ _processed_images.append({
3827
+ "url": f"data:{mime};base64,{b64}",
3828
+ "type": mime,
3829
+ "name": img.get("name", ""),
3830
+ })
3831
+ if _processed_images:
3832
+ context.metadata["user_images"] = _processed_images
3833
+ logger.info(f"[{session_id}] 用户发送了 {len(_processed_images)} 张图片")
3834
+
3835
+ # [v1.16.12] 处理用户文件附件 — 提取文本内容附加到消息中
3836
+ if user_files:
3837
+ _file_texts = []
3838
+ for f in user_files:
3839
+ fname = f.get("name", "unknown")
3840
+ ftype = f.get("type", "")
3841
+ fdata_b64 = f.get("data", "")
3842
+ if not fdata_b64:
3843
+ continue
3844
+ try:
3845
+ import base64 as _b64mod
3846
+ fbytes = _b64mod.b64decode(fdata_b64)
3847
+ _text = self._extract_text_from_file(fname, ftype, fbytes)
3848
+ if _text:
3849
+ _file_texts.append(f"--- 文件: {fname} ---\n{_text}")
3850
+ except Exception as _fe:
3851
+ logger.warning(f"[{session_id}] 文件 {fname} 提取失败: {_fe}")
3852
+ if _file_texts:
3853
+ file_context = "\n\n".join(_file_texts)
3854
+ context.metadata["user_file_texts"] = file_context
3855
+ # 将文件内容附加到 user_message 中
3856
+ user_message = f"{user_message}\n\n[附件内容]\n{file_context}" if user_message else f"[附件内容]\n{file_context}"
3857
+ context.user_message = user_message
3858
+ logger.info(f"[{session_id}] 用户发送了 {len(user_files)} 个文件,提取文本 {len(file_context)} 字符")
3859
+
3705
3860
  # ── 根据 Agent 配置设置执行引擎参数(execution_mode 等)──
3706
3861
  agent_cfg_for_exec = self._read_agent_config(agent_path)
3707
3862
  _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,203 @@ 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
+ // 创建隐藏的文件输入
2780
+ if (!document.getElementById('imageFileInput')) {
2781
+ var imgInput = document.createElement('input');
2782
+ imgInput.type = 'file';
2783
+ imgInput.id = 'imageFileInput';
2784
+ imgInput.accept = 'image/*';
2785
+ imgInput.multiple = true;
2786
+ imgInput.style.display = 'none';
2787
+ imgInput.onchange = function() { handleFileSelect(this, 'image'); this.value = ''; };
2788
+ document.body.appendChild(imgInput);
2789
+ }
2790
+ if (!document.getElementById('docFileInput')) {
2791
+ var docInput = document.createElement('input');
2792
+ docInput.type = 'file';
2793
+ docInput.id = 'docFileInput';
2794
+ 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';
2795
+ docInput.multiple = true;
2796
+ docInput.style.display = 'none';
2797
+ docInput.onchange = function() { handleFileSelect(this, 'file'); this.value = ''; };
2798
+ document.body.appendChild(docInput);
2799
+ }
2800
+ }
2801
+
2802
+ function handleFileSelect(input, type) {
2803
+ var files = input.files;
2804
+ if (!files || files.length === 0) return;
2805
+
2806
+ for (var i = 0; i < files.length; i++) {
2807
+ (function(file) {
2808
+ var reader = new FileReader();
2809
+ reader.onload = function(e) {
2810
+ var base64 = e.target.result.split(',')[1];
2811
+ if (type === 'image' && file.type.startsWith('image/')) {
2812
+ window._pendingImages.push({
2813
+ type: file.type,
2814
+ data: base64,
2815
+ name: file.name,
2816
+ size: file.size,
2817
+ });
2818
+ } else {
2819
+ window._pendingFiles.push({
2820
+ type: file.type,
2821
+ data: base64,
2822
+ name: file.name,
2823
+ size: file.size,
2824
+ });
2825
+ }
2826
+ renderAttachmentPreview();
2827
+ updateSendBtnState();
2828
+ };
2829
+ reader.readAsDataURL(file);
2830
+ })(files[i]);
2831
+ }
2832
+ }
2833
+
2834
+ function handlePasteEvent(e) {
2835
+ var items = e.clipboardData && e.clipboardData.items;
2836
+ if (!items) return;
2837
+ var hasImage = false;
2838
+ for (var i = 0; i < items.length; i++) {
2839
+ if (items[i].type.startsWith('image/')) {
2840
+ e.preventDefault();
2841
+ hasImage = true;
2842
+ var file = items[i].getAsFile();
2843
+ if (file) {
2844
+ (function(f) {
2845
+ var reader = new FileReader();
2846
+ reader.onload = function(ev) {
2847
+ var base64 = ev.target.result.split(',')[1];
2848
+ window._pendingImages.push({
2849
+ type: f.type,
2850
+ data: base64,
2851
+ name: 'paste-' + Date.now() + '.png',
2852
+ size: f.size,
2853
+ });
2854
+ renderAttachmentPreview();
2855
+ updateSendBtnState();
2856
+ };
2857
+ reader.readAsDataURL(f);
2858
+ })(file);
2859
+ }
2860
+ }
2861
+ }
2862
+ }
2863
+
2864
+ function handleDropEvent(e) {
2865
+ e.preventDefault();
2866
+ e.stopPropagation();
2867
+ var dt = e.dataTransfer;
2868
+ if (!dt || !dt.files || dt.files.length === 0) return;
2869
+ for (var i = 0; i < dt.files.length; i++) {
2870
+ (function(file) {
2871
+ var reader = new FileReader();
2872
+ reader.onload = function(ev) {
2873
+ var base64 = ev.target.result.split(',')[1];
2874
+ if (file.type.startsWith('image/')) {
2875
+ window._pendingImages.push({ type: file.type, data: base64, name: file.name, size: file.size });
2876
+ } else {
2877
+ window._pendingFiles.push({ type: file.type, data: base64, name: file.name, size: file.size });
2878
+ }
2879
+ renderAttachmentPreview();
2880
+ updateSendBtnState();
2881
+ };
2882
+ reader.readAsDataURL(file);
2883
+ })(dt.files[i]);
2884
+ }
2885
+ }
2886
+
2887
+ function renderAttachmentPreview() {
2888
+ var container = document.getElementById('attachmentPreview');
2889
+ if (!container) {
2890
+ // 创建预览容器(在 input-box 之前)
2891
+ var inputBox = document.getElementById('inputBox');
2892
+ if (!inputBox) return;
2893
+ container = document.createElement('div');
2894
+ container.id = 'attachmentPreview';
2895
+ container.className = 'attachment-preview';
2896
+ inputBox.parentNode.insertBefore(container, inputBox);
2897
+ }
2898
+
2899
+ var html = '';
2900
+ // 图片预览
2901
+ var imgs = window._pendingImages || [];
2902
+ for (var i = 0; i < imgs.length; i++) {
2903
+ var img = imgs[i];
2904
+ html += '<div class="attachment-thumb attachment-thumb-image" onclick="removeAttachment(\'image\',' + i + ')">' +
2905
+ '<img src="data:' + (img.type || 'image/png') + ';base64,' + img.data + '" />' +
2906
+ '<button class="attachment-remove" onclick="event.stopPropagation();removeAttachment(\'image\',' + i + ')">×</button>' +
2907
+ '</div>';
2908
+ }
2909
+ // 文件预览
2910
+ var files = window._pendingFiles || [];
2911
+ for (var j = 0; j < files.length; j++) {
2912
+ var f = files[j];
2913
+ html += '<div class="attachment-thumb attachment-thumb-file" onclick="removeAttachment(\'file\',' + j + ')">' +
2914
+ '<span class="attachment-file-icon">' + _getFileIcon(f.name) + '</span>' +
2915
+ '<span class="attachment-file-name">' + escapeHtml(f.name) + '</span>' +
2916
+ '<button class="attachment-remove" onclick="event.stopPropagation();removeAttachment(\'file\',' + j + ')">×</button>' +
2917
+ '</div>';
2918
+ }
2919
+ container.innerHTML = html;
2920
+ container.style.display = html ? 'flex' : 'none';
2921
+ }
2922
+
2923
+ function removeAttachment(type, index) {
2924
+ if (type === 'image') {
2925
+ (window._pendingImages || []).splice(index, 1);
2926
+ } else {
2927
+ (window._pendingFiles || []).splice(index, 1);
2928
+ }
2929
+ renderAttachmentPreview();
2930
+ updateSendBtnState();
2931
+ }
2932
+
2933
+ function clearAttachmentPreview() {
2934
+ var container = document.getElementById('attachmentPreview');
2935
+ if (container) { container.innerHTML = ''; container.style.display = 'none'; }
2936
+ }
2937
+
2938
+ function updateSendBtnState() {
2939
+ var input = document.getElementById('userInput');
2940
+ var btn = document.getElementById('sendBtn');
2941
+ if (!btn) return;
2942
+ var hasContent = input && input.value.trim().length > 0;
2943
+ var hasAttach = (window._pendingImages || []).length > 0 || (window._pendingFiles || []).length > 0;
2944
+ btn.disabled = !hasContent && !hasAttach;
2945
+ }
2946
+
2722
2947
  function escapeHtml(text) {
2723
2948
  if (!text) return '';
2724
2949
  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
  });