myagent-ai 1.16.10 → 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.
- package/agents/main_agent.py +18 -5
- package/core/llm.py +22 -3
- package/package.json +1 -1
- package/web/api_server.py +163 -4
- package/web/ui/chat/chat.css +77 -1
- package/web/ui/chat/chat_container.html +11 -2
- package/web/ui/chat/chat_main.js +225 -0
- package/web/ui/chat/flow_engine.js +31 -1
package/agents/main_agent.py
CHANGED
|
@@ -634,17 +634,30 @@ class MainAgent(BaseAgent):
|
|
|
634
634
|
))
|
|
635
635
|
all_tool_outputs = ""
|
|
636
636
|
else:
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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.
|
|
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:
|
|
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
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
|
-
|
|
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)
|
|
@@ -1140,6 +1255,8 @@ class ApiServer:
|
|
|
1140
1255
|
|
|
1141
1256
|
wav_buf.seek(0)
|
|
1142
1257
|
segments, info = whisper_model.transcribe(wav_buf, beam_size=1,
|
|
1258
|
+
language="zh",
|
|
1259
|
+
initial_prompt="以下是普通话的句子",
|
|
1143
1260
|
vad_filter=True, vad_parameters=dict(
|
|
1144
1261
|
min_silence_duration_ms=300))
|
|
1145
1262
|
text = "".join(seg.text for seg in segments).strip()
|
|
@@ -1188,6 +1305,8 @@ class ApiServer:
|
|
|
1188
1305
|
return web.json_response({"error": "音频格式不支持"}, status=400)
|
|
1189
1306
|
wav_buf.seek(0)
|
|
1190
1307
|
segments, info = whisper_model.transcribe(wav_buf, beam_size=1,
|
|
1308
|
+
language="zh",
|
|
1309
|
+
initial_prompt="以下是普通话的句子",
|
|
1191
1310
|
vad_filter=True, vad_parameters=dict(
|
|
1192
1311
|
min_silence_duration_ms=300))
|
|
1193
1312
|
text = "".join(seg.text for seg in segments).strip()
|
|
@@ -3670,7 +3789,7 @@ class ApiServer:
|
|
|
3670
3789
|
|
|
3671
3790
|
async def _stream_process_message(self, user_message, session_id, stream_response,
|
|
3672
3791
|
agent_path=None, agent_system_prompt=None, chat_mode="",
|
|
3673
|
-
voice_text=""):
|
|
3792
|
+
voice_text="", user_images=None, user_files=None):
|
|
3674
3793
|
"""使用流式LLM调用处理消息,支持完整的agent循环(工具调用/操作执行)+ 实时流式输出
|
|
3675
3794
|
|
|
3676
3795
|
核心改进:
|
|
@@ -3678,8 +3797,7 @@ class ApiServer:
|
|
|
3678
3797
|
- 使用 frequency_penalty 减少大模型重复输出
|
|
3679
3798
|
- 最终保存时使用累积文本而非 final_response,确保完整内容不丢失
|
|
3680
3799
|
|
|
3681
|
-
|
|
3682
|
-
但将 LLM 的文本响应逐 token 流式推送到 SSE。
|
|
3800
|
+
[v1.16.12] 新增 user_images/user_files 参数支持多模态消息
|
|
3683
3801
|
"""
|
|
3684
3802
|
logger.info(f"[{session_id}] _stream_process_message 开始处理,chat_mode={chat_mode}")
|
|
3685
3803
|
if not self.core.main_agent or not self.core.llm:
|
|
@@ -3698,6 +3816,47 @@ class ApiServer:
|
|
|
3698
3816
|
context.metadata["chat_mode"] = chat_mode
|
|
3699
3817
|
context.metadata["user_voice_text"] = voice_text # 语音输入原始文本(用于 usersays_correct)
|
|
3700
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
|
+
|
|
3701
3860
|
# ── 根据 Agent 配置设置执行引擎参数(execution_mode 等)──
|
|
3702
3861
|
agent_cfg_for_exec = self._read_agent_config(agent_path)
|
|
3703
3862
|
_original_exec_mode = None
|
package/web/ui/chat/chat.css
CHANGED
|
@@ -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:
|
|
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
|
|
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>
|
package/web/ui/chat/chat_main.js
CHANGED
|
@@ -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
|
-
|
|
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
|
});
|