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 +3 -1
- package/agents/main_agent.py +18 -5
- package/core/llm.py +64 -5
- package/package.json +1 -1
- package/web/api_server.py +164 -6
- package/web/ui/chat/chat.css +77 -1
- package/web/ui/chat/chat_container.html +11 -2
- package/web/ui/chat/chat_main.js +297 -0
- package/web/ui/chat/flow_engine.js +31 -1
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
|
-
|
|
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,
|
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(
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
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"
|
|
@@ -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
|
-
|
|
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
|
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,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
|
-
|
|
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
|
});
|