myagent-ai 1.19.2 → 1.19.4

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.
@@ -45,8 +45,8 @@ class MainAgent(BaseAgent):
45
45
  <output>
46
46
  <mainsubject>当前对话的6字以内标题(每轮都需输出,系统会每3轮自动更新会话名称)</mainsubject>
47
47
  <usersays_correct>通过修正识别错误、调整标点,结合上下文,将用户语音转写文本"usersays"修正为更准确的文本,但尽量少改动。如"usersays"为空,则此处为空。</usersays_correct>
48
- <response><reply>展示给用户的文本内容:直接回应用户问题;告诉用户,为完成任务,准备如何开展工作;根据工具调用结果,展示任务进展。注意:这是给用户展示信息的最重要标签。</reply><toolstocal>
49
- <tool><beforecalltext>展示给用户的工具调用信息,方便用户了解调用目的。格式:先使用"接下来、下一步、接着、现在、然后"等连接词,然后介绍调用什么工具,达到什么目的。</beforecalltext><toolname>工具名,用于后台解析器解析调用工具</toolname><parms>调用工具的JSON格式参数对象,如格式: {"query": "搜索关键词", "num": 5}</parms><timeout>预估调用超时时限(秒),工具调用超时会立即回调大语言模型,方便调整工具使用</timeout></tool>
48
+ <response><reply>展示给用户的文本,格式上尽量使用md格式,直观形象展示,甚至可以包括超链接等。内容上,直接回应用户问题;告诉用户,为完成任务,准备如何开展工作;根据工具调用结果,展示任务进展;任务完成后的最终总结。注意:这是给用户展示信息的最重要标签。</reply><toolstocal>
49
+ <tool><beforecalltext>展示给用户的工具调用信息,方便用户了解调用目的。格式:先使用"接下来、下一步、接着、现在、然后、最后"等连接词,然后介绍调用什么工具,达到什么目的。</beforecalltext><toolname>工具名,用于后台解析器解析调用工具</toolname><parms>调用工具的JSON格式参数对象,如格式: {"query": "搜索关键词", "num": 5}</parms><timeout>预估调用超时时限(秒),工具调用超时会立即回调大语言模型,方便调整工具使用</timeout></tool>
50
50
  </toolstocal>
51
51
  </response>
52
52
  <task_plan>仅复杂任务使用任务计划,如"context"包含非空"task_plan",则更新它。否则,先评估任务复杂度,针对单次查询、简单问答、格式转换、单文件修改、简单计算等简单任务,若预计操作步骤不超过3步,则此处输出为空,不要创建任务列表;针对多文件修改、需要调研+实现+测试、涉及多个模块联动等复杂任务,如预计超过3步操作,则以Markdown列表格式制定新任务列表。格式:每项用 "- [ ] 任务描述" 或 "- [x] 已完成任务",含完成状态标记。</task_plan>
@@ -61,13 +61,13 @@ class MainAgent(BaseAgent):
61
61
  </output>
62
62
 
63
63
  事项注意:
64
- 1. toolstocal标签: 可列出所有需要执行的工具调用,可以多个工具。解析器会按顺序执行工具调用,最终全部执行完后,会连同所有结果,回调大语言模型。如果某个工具执行超时了,也会回调回调大模型,让大模型分析为什么超时,改用其他工具。
64
+ 1. toolstocal标签: 可列出所有需要执行的工具调用,可以多个工具。解析器会按顺序执行工具调用,最终全部执行完后,会连同所有结果,回调大语言模型。如果某个工具执行超时了,也会回调回调大模型,让大模型分析为什么超时,改用其他工具。
65
65
  2. 上下文中的记忆系统说明
66
66
  - <automemory>: 系统自动根据你通过 <remember> 保存的记忆和当前用户输入,搜索出的 top10 相关记忆。这些是你过去主动记住的内容(包含时间信息),可供参考。
67
67
  - <recall_memory>: 你在上一轮通过 <recall> 指定的记忆搜索结果。系统根据你提供的关键字和时间点搜索了 top5 相关记忆。
68
68
  - 两种记忆互补:automemory 是自动匹配的,recall_memory 是你主动指定搜索的。如果 automemory 不足,使用 <recall> 请求更多。
69
69
  3. 工具选择指南
70
- - **搜索信息**: 用 `web_search`(返回标题+URL+摘要),不要用 browser_open
70
+ - **搜索信息**: 用 `web_search`(返回标题+URL+摘要)
71
71
  - **读取网页内容**: 用 `web_read`(传入URL,提取正文)
72
72
  - **浏览器交互**(填表、点击、截图等): 才使用 browser_open / browser_click 等
73
73
  - **执行代码**: 用 `code` 工具(language: python/javascript/shell)
@@ -75,6 +75,24 @@ class MainAgent(BaseAgent):
75
75
  - **文件操作**: 用 `file_read` / `file_write` / `file_list` 等文件工具
76
76
  - **发送文件给用户**: 用 `file_send` 工具(参数: file_path=文件路径, description=说明),当你生成或处理了文件需要返回给用户时使用
77
77
  - **主动召回记忆**: 用 `recall_memory` 工具(参数: keyword=关键字, time_point=可选时间点如"2025-01", limit=数量默认5),根据关键字和时间搜索历史记忆
78
+ 4. 准备好内容后,最后,再检查输出格式,确保满足以下要求:
79
+ <output>
80
+ <mainsubject>当前对话的6字以内标题(每轮都需输出,系统会每3轮自动更新会话名称)</mainsubject>
81
+ <usersays_correct>通过修正识别错误、调整标点,结合上下文,将用户语音转写文本"usersays"修正为更准确的文本,但尽量少改动。如"usersays"为空,则此处为空。</usersays_correct>
82
+ <response><reply>展示给用户的文本,格式上尽量使用md格式,直观形象展示,甚至可以包括超链接等。内容上,直接回应用户问题;告诉用户,为完成任务,准备如何开展工作;根据工具调用结果,展示任务进展;任务完成后的最终总结。注意:这是给用户展示信息的最重要标签。</reply><toolstocal>
83
+ <tool><beforecalltext>展示给用户的工具调用信息,方便用户了解调用目的。格式:先使用"接下来、下一步、接着、现在、然后、最后"等连接词,然后介绍调用什么工具,达到什么目的。</beforecalltext><toolname>工具名,用于后台解析器解析调用工具</toolname><parms>调用工具的JSON格式参数对象,如格式: {"query": "搜索关键词", "num": 5}</parms><timeout>预估调用超时时限(秒),工具调用超时会立即回调大语言模型,方便调整工具使用</timeout></tool>
84
+ </toolstocal>
85
+ </response>
86
+ <task_plan>仅复杂任务使用任务计划,如"context"包含非空"task_plan",则更新它。否则,先评估任务复杂度,针对单次查询、简单问答、格式转换、单文件修改、简单计算等简单任务,若预计操作步骤不超过3步,则此处输出为空,不要创建任务列表;针对多文件修改、需要调研+实现+测试、涉及多个模块联动等复杂任务,如预计超过3步操作,则以Markdown列表格式制定新任务列表。格式:每项用 "- [ ] 任务描述" 或 "- [x] 已完成任务",含完成状态标记。</task_plan>
87
+ <remember><type>填global或session,其中"global"为跨会话全局记忆,"session"为仅当前会话。</type><content>仅从最新用户输入,包括"userprint"或"usersays_correct"或工具调用结果,中提炼值得记忆的信息(如用户偏好、重要结论、错误经验、用户个人信息、对话要点、用户诉求、ai回复等)。因为对话默认不自动保存聊天记录,而是从记忆库搜索最相关的最新内容到"automemory"供决策,所以此次必须有所记忆,才能为后续多轮对话提供持续记忆基础。</content></remember>
88
+ <recall>下一轮需要主动召回的记忆描述。填写需要从记忆库中检索的关键字或描述。如果不填写则为空。如果需要更多记忆支持当前任务,填写相关关键词(可包含时间参考,如"2025年1月的项目"),系统将在下一轮搜索top5相关记忆并通过"recall_memory"注入上下文。也可直接调用"recall_memory"工具即时搜索。</recall>
89
+ <knowledge>从本轮对话或工具执行结果中提炼值得长期保存到知识库的专业知识、事实、经验法则、技术要点等,将被持久化存储,未来可通过 "get_knowledge"检索复用。如果本轮无需保存的知识,则为空。格式要求:简洁明确,每条知识一行,用换行分隔。</knowledge>
90
+ <get_knowledge>下一轮执行时需要从知识库搜索获得的知识,填写检索关键词或描述。如context中已包含充足的knowledge内容,则为空。如需更多专业知识支撑,则填写相关搜索词。</get_knowledge>
91
+ <askuser>需要询问用户的内容,如无,则为空</askuser>
92
+ <finish>true/false,是否结束循环调用llm。如"askuser"为非空,则"finish"为true。否则,根据"context"判断任务是否已完成,是否结束llm回调</finish>
93
+ <finish_reason>当"finish"为true 时必填,详细说明为什么现在结束任务(如:任务已完成/需要用户补充信息/信息不足无法继续等)。finish若为false ,此处为空。</finish_reason>
94
+ <next_step>当 finish=false 时必填,描述下一步计划做什么(简洁明了,1-2句话)。finish=true 时为空。</next_step>
95
+ </output>
78
96
  """
79
97
 
80
98
  def __init__(self, tool_agent=None, memory_agent=None, **kwargs):
package/install.ps1 CHANGED
@@ -9,7 +9,6 @@ $VENV_DIR = ".venv"
9
9
  Write-Host "`n--- MyAgent 一键部署 ---" -ForegroundColor Cyan
10
10
 
11
11
  # 1. 检查 Python
12
- # 尝试使用 python 或 python3
13
12
  $PYTHON_EXE = "python"
14
13
  if (-not (Get-Command $PYTHON_EXE -ErrorAction SilentlyContinue)) {
15
14
  $PYTHON_EXE = "python3"
@@ -20,7 +19,6 @@ if (-not (Get-Command $PYTHON_EXE -ErrorAction SilentlyContinue)) {
20
19
  }
21
20
 
22
21
  try {
23
- # 更加稳健的版本获取方式
24
22
  $py_ver_str = & $PYTHON_EXE -c "import sys; print('%d.%d' % sys.version_info[:2])"
25
23
  Write-Host "[i] 找到 Python: $py_ver_str"
26
24
 
@@ -50,20 +48,42 @@ if (-not (Test-Path $VENV_DIR)) {
50
48
  Write-Host "[✓] 虚拟环境已创建。" -ForegroundColor Green
51
49
  }
52
50
 
53
- # 3. 安装依赖
54
- Write-Host "[*] 正在安装/升级依赖..." -ForegroundColor Cyan
55
- & "$VENV_DIR\Scripts\python.exe" -m pip install --upgrade pip --quiet
56
- & "$VENV_DIR\Scripts\python.exe" -m pip install -r requirements.txt
51
+ $PIP = "$VENV_DIR\Scripts\python.exe -m pip"
57
52
 
58
- if ($LASTEXITCODE -eq 0) {
59
- Write-Host "`n[] 部署成功!" -ForegroundColor Green
60
- Write-Host "提示: 现在可以运行 ./start.ps1 启动 MyAgent。" -ForegroundColor Yellow
61
- } else {
62
- Write-Host "`n[] 依赖安装过程中出现错误。" -ForegroundColor Red
53
+ # 3. 升级 pip
54
+ Write-Host "[*] 正在升级 pip..." -ForegroundColor Cyan
55
+ & $VENV_DIR\Scripts\python.exe -m pip install --upgrade pip --quiet
56
+ if ($LASTEXITCODE -ne 0) {
57
+ Write-Host "[!] pip 升级失败,继续尝试安装依赖..." -ForegroundColor Yellow
63
58
  }
64
59
 
60
+ # 4. 安装核心依赖 (必需,失败则中止)
61
+ Write-Host "[*] 正在安装核心依赖..." -ForegroundColor Cyan
62
+ & $VENV_DIR\Scripts\python.exe -m pip install -r requirements.txt
63
+ if ($LASTEXITCODE -ne 0) {
64
+ Write-Host "`n[✗] 核心依赖安装失败!" -ForegroundColor Red
65
+ exit 1
66
+ }
67
+ Write-Host "[✓] 核心依赖安装成功。" -ForegroundColor Green
68
+
69
+ # 5. 安装可选依赖 (失败不影响核心功能)
70
+ if (Test-Path "requirements-optional.txt") {
71
+ Write-Host "`n[*] 正在安装可选依赖 (语音识别等)..." -ForegroundColor Cyan
72
+ Write-Host " (如果编译失败,不影响核心功能)" -ForegroundColor DarkGray
73
+ & $VENV_DIR\Scripts\python.exe -m pip install -r requirements-optional.txt
74
+ if ($LASTEXITCODE -eq 0) {
75
+ Write-Host "[✓] 可选依赖安装成功。" -ForegroundColor Green
76
+ } else {
77
+ Write-Host "[!] 可选依赖安装失败(部分功能如语音识别可能不可用)。" -ForegroundColor Yellow
78
+ Write-Host " 提示: funasr 在 Windows + Python 3.14 上需要安装 Microsoft C++ Build Tools" -ForegroundColor DarkGray
79
+ Write-Host " 下载地址: https://visualstudio.microsoft.com/visual-cpp-build-tools/" -ForegroundColor DarkGray
80
+ }
81
+ }
82
+
83
+ Write-Host "`n[✓] 部署完成!" -ForegroundColor Green
84
+ Write-Host "提示: 现在可以运行 .\myagent_start.ps1 启动 MyAgent。" -ForegroundColor Yellow
85
+
65
86
  Write-Host "`n按回车退出..."
66
- # 仅在非交互模式下禁用 Read-Host
67
87
  if ($Host.Name -ne "ServerRemoteHost") {
68
88
  Read-Host
69
89
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "myagent-ai",
3
- "version": "1.19.2",
3
+ "version": "1.19.4",
4
4
  "description": "本地桌面端执行型AI助手 - Open Interpreter 风格 | Local Desktop Execution-Oriented AI Assistant",
5
5
  "main": "main.py",
6
6
  "bin": {
@@ -43,4 +43,4 @@
43
43
  "python": ">=3.10",
44
44
  "node": ">=18"
45
45
  }
46
- }
46
+ }
@@ -0,0 +1,10 @@
1
+ # MyAgent 可选依赖 - 语音识别等需要编译/大体积的包
2
+ # 安装失败不影响核心功能
3
+ # 注意: funasr 依赖 editdistance,在 Windows + Python 3.14 上需要 MSVC 编译器
4
+ # 若编译失败,语音识别会 fallback 到 Whisper 引擎
5
+
6
+ funasr>=1.1.0
7
+ torch>=2.0.0
8
+ torchaudio>=2.0.0
9
+ faster-whisper>=1.0.0
10
+ pydub>=0.25.1
package/requirements.txt CHANGED
@@ -69,18 +69,6 @@ chardet>=5.0.0
69
69
  # xlrd: 旧版 Excel (.xls) 文件读取
70
70
  xlrd>=2.0.0
71
71
 
72
- # ============================================================
73
- # 语音识别 (本地 STT,默认启用)
74
- # ============================================================
75
- # [v1.18.8] SenseVoice (funasr) 作为首选引擎,中文识别极佳
76
- # torch/torchaudio 约 200MB (CPU版),funasr 约 100MB
77
- # 若仅需 Whisper 备选引擎,可注释下面三行,保留 faster-whisper
78
- funasr>=1.1.0
79
- torch>=2.0.0
80
- torchaudio>=2.0.0
81
- faster-whisper>=1.0.0
82
- pydub>=0.25.1
83
-
84
72
  # ============================================================
85
73
  # Anthropic Claude (可选)
86
74
  # ============================================================
@@ -91,3 +79,8 @@ anthropic>=0.18.0
91
79
  # ============================================================
92
80
  cryptography>=41.0.0
93
81
  websockets>=12.0
82
+
83
+ # ============================================================
84
+ # 语音识别 (本地 STT,需要编译)
85
+ # 已移至 requirements-optional.txt,单独安装以避免编译失败阻断核心依赖
86
+ # ============================================================
@@ -128,6 +128,9 @@ class SkillRegistry:
128
128
  def auto_discover(self, package: str = "skills"):
129
129
  """
130
130
  自动发现并注册 skills/ 目录下的所有内置技能。
131
+ 支持两种格式:
132
+ 1. Python Skill 子类(*_skill.py 文件)
133
+ 2. SKILL.md 目录格式(目录下包含 SKILL.md 的 markdown 技能)
131
134
 
132
135
  Args:
133
136
  package: 技能包路径
@@ -136,6 +139,7 @@ class SkillRegistry:
136
139
  if not skills_dir.exists():
137
140
  return
138
141
 
142
+ # 格式1: 扫描 *_skill.py 文件
139
143
  for file in skills_dir.glob("*_skill.py"):
140
144
  if file.name.startswith("_") or file.name == "base.py":
141
145
  continue
@@ -159,6 +163,94 @@ class SkillRegistry:
159
163
  except Exception as e:
160
164
  logger.warning(f"模块导入失败 ({module_name}): {e}")
161
165
 
166
+ # 格式2: 扫描 SKILL.md 目录格式的技能
167
+ for subdir in skills_dir.iterdir():
168
+ if not subdir.is_dir() or subdir.name.startswith("_") or subdir.name.startswith("."):
169
+ continue
170
+ skill_md = subdir / "SKILL.md"
171
+ if not skill_md.exists():
172
+ continue
173
+ try:
174
+ instance = _load_skill_from_md(subdir)
175
+ if instance and instance.name not in self._skills:
176
+ self.register(instance)
177
+ logger.info(f"自动发现 SKILL.md 技能: {instance.name} ({subdir.name})")
178
+ except Exception as e:
179
+ logger.warning(f"SKILL.md 技能加载失败 ({subdir.name}): {e}")
180
+
181
+
182
+ def _load_skill_from_md(skill_dir: Path) -> Optional[Skill]:
183
+ """从 SKILL.md 目录加载一个 Skill 实例"""
184
+ import yaml # lazy import
185
+
186
+ skill_md_path = skill_dir / "SKILL.md"
187
+ if not skill_md_path.exists():
188
+ return None
189
+
190
+ content = skill_md_path.read_text(encoding="utf-8")
191
+
192
+ # 解析 YAML front matter
193
+ if not content.startswith("---"):
194
+ return None
195
+ parts = content.split("---", 2)
196
+ if len(parts) < 3:
197
+ return None
198
+ try:
199
+ meta = yaml.safe_load(parts[1]) or {}
200
+ except Exception:
201
+ meta = {}
202
+
203
+ name = meta.get("name", skill_dir.name)
204
+ description = meta.get("description", "")
205
+ if isinstance(description, list):
206
+ description = "\n".join(description)
207
+ description = description.strip()
208
+ category = meta.get("metadata", {}).get("category", "") if isinstance(meta.get("metadata"), dict) else ""
209
+ version = meta.get("metadata", {}).get("version", "") if isinstance(meta.get("metadata"), dict) else ""
210
+
211
+ # 计算 references 目录中的参考文档
212
+ ref_dir = skill_dir / "references"
213
+ references = []
214
+ if ref_dir.exists():
215
+ for ref_file in sorted(ref_dir.glob("*.md")):
216
+ references.append(ref_file.name)
217
+
218
+ class MarkdownSkill(Skill):
219
+ """SKILL.md 格式的技能包装器"""
220
+ def __init__(self):
221
+ super().__init__()
222
+ self.name = name
223
+ self.description = description or f"Markdown 技能 ({name})"
224
+ self.parameters = []
225
+ # 存储额外元信息
226
+ self._skill_type = "markdown"
227
+ self._skill_dir = str(skill_dir)
228
+ self._category = category
229
+ self._version = version
230
+ self._references = references
231
+ self._has_templates = (skill_dir / "templates").exists()
232
+ self._has_scripts = (skill_dir / "scripts").exists()
233
+
234
+ async def execute(self, **kwargs) -> SkillResult:
235
+ return SkillResult(
236
+ success=False,
237
+ error=f"SKILL.md 格式技能不支持直接调用,请通过 LLM 使用 /{self.name} 触发",
238
+ )
239
+
240
+ def to_openclaw_format(self) -> Dict[str, Any]:
241
+ info = super().to_openclaw_format()
242
+ info["skill_type"] = "markdown"
243
+ info["category"] = self._category
244
+ if self._version:
245
+ info["version"] = self._version
246
+ if self._references:
247
+ info["references"] = self._references
248
+ info["has_templates"] = self._has_templates
249
+ info["has_scripts"] = self._has_scripts
250
+ return info
251
+
252
+ return MarkdownSkill()
253
+
162
254
 
163
255
  # ==============================================================================
164
256
  # 全局注册表
@@ -636,10 +636,16 @@ input,textarea,select{font:inherit}
636
636
  }
637
637
  .attachment-thumb:hover .attachment-remove{display:flex}
638
638
 
639
- /* [v1.16.12→18] 消息气泡中的附件 */
639
+ /* [v1.16.12→18→19.3] 消息气泡中的附件 */
640
640
  .msg-attachments {
641
641
  display:flex;flex-wrap:wrap;gap:8px;margin-bottom:8px;
642
642
  }
643
+ .msg-attachments-images {
644
+ margin-bottom:10px; /* 图片在气泡内容上方,底部留更多空间 */
645
+ }
646
+ .msg-attachments-files {
647
+ margin-top:6px; /* 文件在气泡内容下方 */
648
+ }
643
649
  .msg-image-wrapper {
644
650
  max-width:300px;border-radius:var(--radius-sm);overflow:hidden;
645
651
  border:1px solid var(--bg4);cursor:pointer;
@@ -652,6 +658,13 @@ input,textarea,select{font:inherit}
652
658
  transition:max-height .3s ease;
653
659
  }
654
660
  .msg-image-wrapper:hover .msg-image{max-height:none}
661
+ /* [v1.19.3] Agent 发送的图片样式 */
662
+ .agent-image {
663
+ border-color:var(--accent);
664
+ }
665
+ .agent-image::after {
666
+ content:'点击查看大图 · ⬇ 下载';
667
+ }
655
668
  /* [v1.16.18] 图片缩略图 hover 叠加层提示 */
656
669
  .msg-image-wrapper::after {
657
670
  content:'点击查看大图';
@@ -666,17 +679,26 @@ input,textarea,select{font:inherit}
666
679
  .msg-file-item {
667
680
  display:flex;align-items:center;gap:8px;padding:6px 10px;
668
681
  background:var(--bg3);border-radius:var(--radius-sm);
669
- font-size:13px;color:var(--text2);max-width:240px;
682
+ font-size:13px;color:var(--text2);max-width:280px;
670
683
  cursor:pointer;transition:background .15s,border-color .15s;
671
684
  border:1px solid transparent;
672
685
  }
673
686
  .msg-file-item:hover{background:var(--bg4);border-color:var(--accent)}
674
687
  .agent-file{border-color:var(--accent);background:var(--bg2)}
675
688
  .msg-file-icon{font-size:18px;flex-shrink:0}
689
+ .msg-file-info{display:flex;flex-direction:column;gap:2px;min-width:0;flex:1}
676
690
  .msg-file-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500}
677
691
  .msg-file-size{font-size:11px;color:var(--text3);flex-shrink:0}
692
+ .msg-file-actions{flex-shrink:0;margin-left:4px}
693
+ .msg-file-download{
694
+ display:inline-flex;align-items:center;justify-content:center;
695
+ width:28px;height:28px;border-radius:var(--radius-xs);
696
+ font-size:16px;text-decoration:none;color:var(--text3);
697
+ transition:background .15s, color .15s;
698
+ }
699
+ .msg-file-download:hover{background:var(--accent);color:#fff}
678
700
 
679
- /* [v1.17] 移动端文件附件适配 */
701
+ /* [v1.17→19.3] 移动端文件附件适配 */
680
702
  @media (max-width: 768px) {
681
703
  .msg-file-item {
682
704
  max-width: 100%;
@@ -689,6 +711,7 @@ input,textarea,select{font:inherit}
689
711
  .msg-file-name { font-size: 14px; }
690
712
  .msg-attachments { gap: 10px; }
691
713
  .msg-image-wrapper { max-width: 100%; }
714
+ .msg-file-download { width: 34px; height: 34px; font-size: 18px; }
692
715
  }
693
716
 
694
717
  /* [v1.17] 折叠文件内容样式 */
@@ -278,6 +278,27 @@
278
278
 
279
279
  </div>
280
280
 
281
+ <!-- [v1.19.2] VNC Remote Desktop Overlay -->
282
+ <div id="vncOverlay" style="display:none;position:fixed;top:0;left:0;width:100%;height:100%;z-index:9999;background:#1a1a2e;flex-direction:column">
283
+ <div style="display:flex;align-items:center;justify-content:space-between;padding:8px 16px;background:#16213e;border-bottom:1px solid #0f3460;flex-shrink:0">
284
+ <div style="display:flex;align-items:center;gap:12px">
285
+ <span style="color:#eee;font-size:14px;font-weight:600">远程桌面</span>
286
+ <span id="vncOverlayStatus" style="font-size:12px;color:#888">正在连接...</span>
287
+ </div>
288
+ <div style="display:flex;gap:8px">
289
+ <button onclick="openVNCInNewTab()" style="padding:4px 12px;border-radius:4px;border:1px solid #0f3460;background:#0f3460;color:#eee;cursor:pointer;font-size:12px" title="在新标签页中打开">新标签页</button>
290
+ <button onclick="closeVNCOverlay()" style="padding:4px 12px;border-radius:4px;border:1px solid #e94560;background:transparent;color:#e94560;cursor:pointer;font-size:12px">关闭</button>
291
+ </div>
292
+ </div>
293
+ <div style="flex:1;position:relative">
294
+ <iframe id="vncIframe" style="width:100%;height:100%;border:none" allow="clipboard-read; clipboard-write"></iframe>
295
+ <div id="vncLoadingHint" style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);color:#888;font-size:14px;text-align:center">
296
+ <div style="margin-bottom:8px;font-size:32px">💻</div>
297
+ <div>正在连接远程桌面...</div>
298
+ </div>
299
+ </div>
300
+ </div>
301
+
281
302
  <!-- Mobile Overlay -->
282
303
  <div class="mobile-overlay" id="chatMobileOverlay"></div>
283
304
  <!-- Toast Container -->
@@ -975,43 +975,80 @@ async function toggleVNC() {
975
975
  return;
976
976
  }
977
977
 
978
- toast('正在启动远程桌面...', 'info');
978
+ toast('正在启动远程桌面,请稍候...', 'info', 60000);
979
979
  try {
980
980
  var result = await api('/api/vnc/start', { method: 'POST' });
981
981
  if (result.success) {
982
982
  await refreshVNCStatus();
983
+ toast('远程桌面已启动,正在打开远程桌面窗口...', 'success');
983
984
  // 如果是"已在运行"(附加到已有实例),立刻打开窗口
984
985
  if (result.message && result.message.indexOf('已在运行') > -1) {
985
- toast('远程桌面已连接', 'success');
986
986
  openVNCWindow();
987
987
  } else {
988
- toast('远程桌面已启动', 'success');
989
988
  // 新启动的需要等服务完全就绪
990
989
  setTimeout(function() {
991
990
  openVNCWindow();
992
991
  }, 2000);
993
992
  }
994
993
  } else {
995
- toast('启动失败: ' + (result.message || '未知错误'), 'error');
994
+ toast('远程桌面启动失败: ' + (result.message || '未知错误'), 'error', 8000);
996
995
  }
997
996
  } catch (e) {
998
- toast('启动失败: ' + e.message, 'error');
997
+ toast('远程桌面启动失败: ' + e.message, 'error', 8000);
999
998
  }
1000
999
  }
1001
1000
 
1002
1001
  function openVNCWindow() {
1002
+ // [v1.19.2] 优先使用页面内覆盖层(兼容 IM WebView 等不允许弹窗的环境)
1003
+ var vncUrl = '/vnc/vnc.html';
1004
+ var overlay = document.getElementById('vncOverlay');
1005
+ var iframe = document.getElementById('vncIframe');
1006
+ if (overlay && iframe) {
1007
+ overlay.style.display = 'flex';
1008
+ iframe.src = vncUrl;
1009
+ // iframe 加载完成后隐藏 loading 提示
1010
+ iframe.onload = function() {
1011
+ var hint = document.getElementById('vncLoadingHint');
1012
+ var status = document.getElementById('vncOverlayStatus');
1013
+ if (hint) hint.style.display = 'none';
1014
+ if (status) status.textContent = '已连接';
1015
+ if (status) status.style.color = '#00ff88';
1016
+ };
1017
+ return;
1018
+ }
1019
+ // 回退: 弹窗方式(仅限非 IM 环境)
1003
1020
  if (vncWindow && !vncWindow.closed) {
1004
1021
  vncWindow.focus();
1005
1022
  return;
1006
1023
  }
1007
- // 在新窗口中打开 noVNC 客户端
1008
1024
  var w = Math.min(1200, screen.width - 100);
1009
1025
  var h = Math.min(800, screen.height - 100);
1010
1026
  var left = Math.round((screen.width - w) / 2);
1011
1027
  var top = Math.round((screen.height - h) / 2);
1012
- vncWindow = window.open('/vnc/vnc.html', 'myagent_vnc',
1028
+ vncWindow = window.open(vncUrl, 'myagent_vnc',
1013
1029
  'width=' + w + ',height=' + h + ',left=' + left + ',top=' + top +
1014
1030
  ',menubar=no,toolbar=no,location=no,status=no,scrollbars=no,resizable=yes');
1031
+ if (!vncWindow || vncWindow.closed) {
1032
+ vncWindow = window.open(vncUrl, '_blank');
1033
+ if (!vncWindow || vncWindow.closed) {
1034
+ toast('浏览器拦截了弹窗,请允许弹窗后重试,或点击菜单远程桌面入口', 'warning', 8000);
1035
+ }
1036
+ }
1037
+ }
1038
+
1039
+ function closeVNCOverlay() {
1040
+ var overlay = document.getElementById('vncOverlay');
1041
+ var iframe = document.getElementById('vncIframe');
1042
+ var hint = document.getElementById('vncLoadingHint');
1043
+ var status = document.getElementById('vncOverlayStatus');
1044
+ if (overlay) overlay.style.display = 'none';
1045
+ if (iframe) iframe.src = 'about:blank';
1046
+ if (hint) hint.style.display = 'block';
1047
+ if (status) { status.textContent = '正在连接...'; status.style.color = '#888'; }
1048
+ }
1049
+
1050
+ function openVNCInNewTab() {
1051
+ window.open('/vnc/vnc.html', '_blank');
1015
1052
  }
1016
1053
 
1017
1054
  async function stopVNC() {
@@ -1021,6 +1058,8 @@ async function stopVNC() {
1021
1058
  var result = await api('/api/vnc/stop', { method: 'POST' });
1022
1059
  toast(result.message || '远程桌面已停止', 'info');
1023
1060
  await refreshVNCStatus();
1061
+ // 关闭 VNC 覆盖层和弹窗
1062
+ closeVNCOverlay();
1024
1063
  if (vncWindow && !vncWindow.closed) {
1025
1064
  vncWindow.close();
1026
1065
  }
@@ -2317,280 +2356,142 @@ async function clearCurrentChat() {
2317
2356
  }
2318
2357
 
2319
2358
  // ── Group History Messages ──
2320
- // Groups consecutive non-user messages (assistant + tool_call + tool_result) into single
2321
- // assistant messages with parts[] for timeline rendering, matching the streaming display.
2359
+ // 将数据库中的扁平消息序列分组为 user/assistant 交替结构,
2360
+ // 每个 assistant 组包含 interleaved text + tool_call + tool_result parts。
2322
2361
  //
2323
- // DB storage pattern:
2324
- // user_input → role="user", key="user_input"
2325
- // text → role="assistant", key=""
2326
- // tool_call → role="assistant", key="tool_call" (NOT role="tool"!)
2327
- // tool_result→ role="tool", key="tool_result"
2328
- //
2329
- // Result: user → [assistant { text → tool_call → tool_result → text → ... }] → user
2362
+ // DB 存储模式:
2363
+ // 用户输入 → role="user", key="user_input"
2364
+ // 助手文本 → role="assistant", key=""
2365
+ // 工具调用 → role="assistant", key="tool_call"
2366
+ // 工具结果 → role="tool", key="tool_result"
2367
+ // 推理思考 → role="assistant", key="reasoning"
2330
2368
 
2331
2369
  // 解析 tool_call 消息内容,兼容新旧格式
2332
- // 新格式: "beforecalltext\n调用工具: xxx\n参数: yyy"
2333
- // 旧格式: "调用工具: xxx\n参数: yyy"
2334
2370
  function parseToolCallContent(content) {
2335
2371
  if (!content) return { title: '调用工具', toolName: '', params: '' };
2336
2372
  var lines = content.split('\n');
2337
- var title = '';
2338
- var toolName = '';
2339
- var params = '';
2340
- // 查找 "调用工具:" 行
2373
+ var toolName = '', params = '';
2341
2374
  for (var li = 0; li < lines.length; li++) {
2342
- var line = lines[li].trim();
2343
- var m = line.match(/^调用工具:\s*(\S+)/);
2344
- if (m) {
2345
- toolName = m[1];
2346
- break;
2347
- }
2375
+ var m = lines[li].trim().match(/^调用工具:\s*(\S+)/);
2376
+ if (m) { toolName = m[1]; break; }
2348
2377
  }
2349
- // 查找 "参数:" 行
2350
2378
  for (var li2 = 0; li2 < lines.length; li2++) {
2351
- var line2 = lines[li2].trim();
2352
- var m2 = line2.match(/^参数:\s*([\s\S]*)/);
2353
- if (m2) {
2354
- params = m2[1].trim();
2355
- break;
2356
- }
2357
- }
2358
- // 标题: 如果第一行不是"调用工具:",则使用第一行作为 beforecalltext 标题
2359
- if (lines[0] && !lines[0].trim().startsWith('调用工具:')) {
2360
- title = lines[0].trim();
2361
- } else {
2362
- title = toolName ? ('调用工具: ' + toolName) : content.substring(0, 100);
2379
+ var m2 = lines[li2].trim().match(/^参数:\s*([\s\S]*)/);
2380
+ if (m2) { params = m2[1].trim(); break; }
2363
2381
  }
2382
+ var tcIdx = lines.findIndex(function(l) { return l.trim().match(/^调用工具:/); });
2383
+ var title = tcIdx > 0 ? lines.slice(0, tcIdx).join('\n').trim() : '';
2384
+ if (!title) title = toolName ? ('调用工具: ' + toolName) : '调用工具';
2364
2385
  return { title: title, toolName: toolName, params: params };
2365
2386
  }
2366
2387
 
2388
+ // 解析 tool_result 消息: "[tool_name] 成功/失败\n{output}"
2389
+ function parseToolResultContent(content) {
2390
+ if (!content) return { title: '执行结果', toolName: '', success: true, body: '' };
2391
+ var idx = content.indexOf('\n');
2392
+ var header = idx > 0 ? content.substring(0, idx) : content.substring(0, 80);
2393
+ var body = idx > 0 ? content.substring(idx + 1) : '';
2394
+ var toolName = (header.match(/^\[([^\]]+)\]/) || [])[1] || '';
2395
+ var isOk = !content.includes('失败');
2396
+ var title = toolName ? (toolName + (isOk ? ' ✓' : ' ✗')) : (isOk ? '执行成功' : '执行失败');
2397
+ return { title: title, toolName: toolName, success: isOk, body: body };
2398
+ }
2399
+
2367
2400
  function groupHistoryMessages(messages) {
2368
2401
  if (!Array.isArray(messages) || messages.length === 0) return messages;
2369
2402
 
2370
- const grouped = [];
2371
- let i = 0;
2372
- let _evtId = 0; // Event ID counter
2403
+ var grouped = [];
2404
+ var evtId = 0;
2405
+
2406
+ // 辅助: 将单条消息解析为 part(reasoning 除外)
2407
+ function msgToPart(m) {
2408
+ var key = (m.key || '').toLowerCase();
2409
+ if (key === 'reasoning') return null;
2410
+ if (key === 'tool_call') {
2411
+ var tc = m._parsedToolCall || parseToolCallContent(m.content);
2412
+ m._parsedToolCall = tc;
2413
+ return { type: 'exec', data: { id: evtId++, type: 'tool_call', title: tc.title, tool_name: tc.toolName, params: tc.params || undefined, status: 'done' } };
2414
+ }
2415
+ if (m.role === 'tool') {
2416
+ var tr = parseToolResultContent(m.content);
2417
+ return { type: 'exec', data: { id: evtId++, type: 'tool_result', title: tr.title, tool_name: tr.toolName, success: tr.success, summary: tr.body.substring(0, 500).trim(), result: { output: tr.body.substring(0, 2000) } } };
2418
+ }
2419
+ if (m.content && m.content.trim() && m.content !== '(无回复)') {
2420
+ return { type: 'text', content: m.content };
2421
+ }
2422
+ return null;
2423
+ }
2373
2424
 
2425
+ var i = 0;
2374
2426
  while (i < messages.length) {
2375
- const msg = messages[i];
2427
+ var msg = messages[i];
2376
2428
 
2429
+ // ── 用户消息: 直接加入 ──
2377
2430
  if (msg.role === 'user') {
2378
2431
  var userEntry = { role: 'user', content: msg.content, time: msg.time || '' };
2379
- // [v1.16.18] 保留附件元数据
2380
2432
  if (msg.images) userEntry.images = msg.images;
2381
2433
  if (msg.files) userEntry.files = msg.files;
2382
2434
  grouped.push(userEntry);
2383
2435
  i++;
2384
- } else if (msg.role === 'assistant') {
2385
- // Start of a new agent group: collect ALL consecutive non-user messages
2386
- const parts = [];
2387
- let lastAssistantTime = msg.time || '';
2388
- let reasoningText = ''; // 推理模型思考过程
2389
-
2390
- // Process the first assistant message
2391
- if (msg.key === 'reasoning') {
2392
- // 推理模型思考过程 — 合并到分组消息的 reasoning 字段
2393
- reasoningText = msg.content || '';
2394
- } else if (msg.key === 'tool_call') {
2395
- // 新格式: beforecalltext\n调用工具: xxx\n参数: yyy
2396
- // 旧格式: 调用工具: xxx\n参数: yyy
2397
- var _tcParts = msg._parsedToolCall || parseToolCallContent(msg.content);
2398
- msg._parsedToolCall = _tcParts; // 缓存
2399
- parts.push({
2400
- type: 'exec',
2401
- data: {
2402
- id: _evtId++,
2403
- type: 'tool_call',
2404
- title: _tcParts.title,
2405
- tool_name: _tcParts.toolName,
2406
- params: _tcParts.params || undefined,
2407
- status: 'done',
2408
- }
2409
- });
2410
- } else if (msg.content && msg.content.trim() && msg.content !== '(无回复)') {
2411
- parts.push({ type: 'text', content: msg.content });
2412
- }
2436
+ continue;
2437
+ }
2413
2438
 
2414
- i++;
2439
+ // ── 收集连续的非 user 消息为一个 assistant 组 ──
2440
+ var parts = [];
2441
+ var reasoningText = '';
2442
+ var lastTime = msg.time || '';
2443
+ var firstMsg = msg;
2444
+ // [v1.19.3] 收集组内所有消息的文件(agent 通过 file_send 发送的文件存在 metadata.files 中)
2445
+ var allAgentFiles = [];
2415
2446
 
2416
- // Collect all following tool_call (role=assistant), tool_result (role=tool),
2417
- // and assistant text messages into the same group.
2418
- // This handles: text tool_call → tool_result → text → tool_call → tool_result → text
2419
- while (i < messages.length) {
2420
- const next = messages[i];
2421
-
2422
- if (next.role === 'tool') {
2423
- // Parse tool_result content: format is "[tool_name] 成功/失败\n{output}"
2424
- const firstNewline = next.content.indexOf('\n');
2425
- const headerLine = firstNewline > 0 ? next.content.substring(0, firstNewline) : next.content.substring(0, 80);
2426
- const bodyContent = firstNewline > 0 ? next.content.substring(firstNewline + 1) : '';
2427
- // Extract tool name from header: [tool_name] 成功 or [tool_name] 失败
2428
- const toolResultName = (headerLine.match(/^\[([^\]]+)\]/) || [])[1] || '';
2429
- const isOk = !next.content.includes('失败');
2430
- const displayTitle = toolResultName ? (toolResultName + (isOk ? ' ✓' : ' ✗')) : (isOk ? '执行成功' : '执行失败');
2431
- parts.push({
2432
- type: 'exec',
2433
- data: {
2434
- id: _evtId++,
2435
- type: 'tool_result',
2436
- title: displayTitle,
2437
- tool_name: toolResultName,
2438
- success: isOk,
2439
- summary: bodyContent.substring(0, 500).trim(),
2440
- result: { output: bodyContent.substring(0, 2000) },
2441
- }
2442
- });
2443
- i++;
2444
- } else if (next.role === 'assistant' && next.key === 'tool_call') {
2445
- // 新格式: beforecalltext\n调用工具: xxx\n参数: yyy
2446
- var _tcParts2 = next._parsedToolCall || parseToolCallContent(next.content);
2447
- next._parsedToolCall = _tcParts2;
2448
- parts.push({
2449
- type: 'exec',
2450
- data: {
2451
- id: _evtId++,
2452
- type: 'tool_call',
2453
- title: _tcParts2.title,
2454
- tool_name: _tcParts2.toolName,
2455
- params: _tcParts2.params || undefined,
2456
- status: 'done',
2457
- }
2458
- });
2459
- lastAssistantTime = next.time || lastAssistantTime;
2460
- i++;
2461
- } else if (next.role === 'assistant') {
2462
- if (next.key === 'reasoning') {
2463
- // 推理模型思考过程 — 追加到 reasoningText
2464
- reasoningText = reasoningText ? (reasoningText + '\n\n' + (next.content || '')) : (next.content || '');
2465
- } else if (next.content && next.content.trim() && next.content !== '(无回复)') {
2466
- parts.push({ type: 'text', content: next.content });
2467
- }
2468
- lastAssistantTime = next.time || lastAssistantTime;
2469
- i++;
2470
- } else {
2471
- break;
2472
- }
2473
- }
2447
+ while (i < messages.length && messages[i].role !== 'user') {
2448
+ var m = messages[i];
2449
+ var mkey = (m.key || '').toLowerCase();
2450
+ lastTime = m.time || lastTime;
2474
2451
 
2475
- const textParts = parts.filter(p => p.type === 'text');
2476
- const assembledContent = textParts.length > 0
2477
- ? textParts[textParts.length - 1].content
2478
- : '';
2479
-
2480
- var _groupedEntry = {
2481
- role: 'assistant',
2482
- content: assembledContent,
2483
- time: lastAssistantTime,
2484
- reasoning: reasoningText || undefined,
2485
- parts: parts.length > 0 ? parts : undefined,
2486
- exec_events: parts.filter(p => p.type === 'exec').map(p => p.data),
2487
- };
2488
- // [fix] 保留 _files 字段(agent 生成的文件附件)
2489
- if (msg._files) _groupedEntry._files = msg._files;
2490
- grouped.push(_groupedEntry);
2491
- } else if (msg.role === 'tool') {
2492
- // Orphan tool message — wrap in an assistant group
2493
- const parts = [];
2494
- const isResult = msg.key === 'tool_result';
2495
- const isCall = msg.key === 'tool_call';
2496
-
2497
- if (isCall) {
2498
- var _tcParts3 = msg._parsedToolCall || parseToolCallContent(msg.content);
2499
- msg._parsedToolCall = _tcParts3;
2500
- parts.push({
2501
- type: 'exec',
2502
- data: {
2503
- id: _evtId++,
2504
- type: 'tool_call',
2505
- title: _tcParts3.title,
2506
- tool_name: _tcParts3.toolName,
2507
- params: _tcParts3.params || undefined,
2508
- status: 'done',
2509
- }
2510
- });
2511
- } else if (isResult) {
2512
- const firstNewline = msg.content.indexOf('\n');
2513
- const headerLine = firstNewline > 0 ? msg.content.substring(0, firstNewline) : msg.content.substring(0, 80);
2514
- const bodyContent = firstNewline > 0 ? msg.content.substring(firstNewline + 1) : '';
2515
- const toolResultName = (headerLine.match(/^\[([^\]]+)\]/) || [])[1] || '';
2516
- const isOk = !msg.content.includes('失败');
2517
- const displayTitle = toolResultName ? (toolResultName + (isOk ? ' ✓' : ' ✗')) : (isOk ? '执行成功' : '执行失败');
2518
- parts.push({
2519
- type: 'exec',
2520
- data: {
2521
- id: _evtId++,
2522
- type: 'tool_result',
2523
- title: displayTitle,
2524
- tool_name: toolResultName,
2525
- success: isOk,
2526
- summary: bodyContent.substring(0, 500).trim(),
2527
- result: { output: bodyContent.substring(0, 2000) },
2528
- }
2529
- });
2452
+ // 收集该消息携带的文件(来自 metadata.files)
2453
+ if (m.files && Array.isArray(m.files) && m.files.length > 0) {
2454
+ for (var fi = 0; fi < m.files.length; fi++) {
2455
+ allAgentFiles.push(m.files[fi]);
2456
+ }
2530
2457
  }
2531
-
2532
- i++;
2533
- while (i < messages.length) {
2534
- const next = messages[i];
2535
- if (next.role === 'assistant' && next.key === 'tool_call') {
2536
- var _tcParts4 = next._parsedToolCall || parseToolCallContent(next.content);
2537
- next._parsedToolCall = _tcParts4;
2538
- parts.push({
2539
- type: 'exec',
2540
- data: {
2541
- id: _evtId++,
2542
- type: 'tool_call',
2543
- title: _tcParts4.title,
2544
- tool_name: _tcParts4.toolName,
2545
- params: _tcParts4.params || undefined,
2546
- status: 'done',
2547
- }
2548
- });
2549
- i++;
2550
- } else if (next.role === 'assistant') {
2551
- if (next.content && next.content.trim() && next.content !== '(无回复)') {
2552
- parts.push({ type: 'text', content: next.content });
2553
- }
2554
- i++;
2555
- } else if (next.role === 'tool') {
2556
- const firstNewline = next.content.indexOf('\n');
2557
- const headerLine = firstNewline > 0 ? next.content.substring(0, firstNewline) : next.content.substring(0, 80);
2558
- const bodyContent = firstNewline > 0 ? next.content.substring(firstNewline + 1) : '';
2559
- const toolResultName = (headerLine.match(/^\[([^\]]+)\]/) || [])[1] || '';
2560
- const isOk = !next.content.includes('失败');
2561
- const displayTitle = toolResultName ? (toolResultName + (isOk ? ' ✓' : ' ✗')) : (isOk ? '执行成功' : '执行失败');
2562
- parts.push({
2563
- type: 'exec',
2564
- data: {
2565
- id: _evtId++,
2566
- type: 'tool_result',
2567
- title: displayTitle,
2568
- tool_name: toolResultName,
2569
- success: isOk,
2570
- summary: bodyContent.substring(0, 500).trim(),
2571
- result: { output: bodyContent.substring(0, 2000) },
2572
- }
2573
- });
2574
- i++;
2575
- } else {
2576
- break;
2458
+ // 也检查流式 _files(兼容实时流切换到历史显示的场景)
2459
+ if (m._files && Array.isArray(m._files) && m._files.length > 0) {
2460
+ for (var _fi = 0; _fi < m._files.length; _fi++) {
2461
+ allAgentFiles.push(m._files[_fi]);
2577
2462
  }
2578
2463
  }
2579
2464
 
2580
- const textParts = parts.filter(p => p.type === 'text');
2581
- var _groupedEntry2 = {
2582
- role: 'assistant',
2583
- content: textParts.length > 0 ? textParts[textParts.length - 1].content : '',
2584
- time: msg.time || '',
2585
- parts: parts.length > 0 ? parts : undefined,
2586
- exec_events: parts.filter(p => p.type === 'exec').map(p => p.data),
2587
- };
2588
- // [fix] 保留 _files 字段(agent 生成的文件附件)
2589
- if (msg._files) _groupedEntry2._files = msg._files;
2590
- grouped.push(_groupedEntry2);
2591
- } else {
2465
+ if (mkey === 'reasoning') {
2466
+ reasoningText = reasoningText ? (reasoningText + '\n\n' + (m.content || '')) : (m.content || '');
2467
+ } else {
2468
+ var part = msgToPart(m);
2469
+ if (part) parts.push(part);
2470
+ }
2592
2471
  i++;
2593
2472
  }
2473
+
2474
+ // 组装: content 取最后一段文本(用于搜索/纯文本回退),parts 展示完整时间线
2475
+ var textParts = parts.filter(function(p) { return p.type === 'text'; });
2476
+ var hasExecParts = parts.some(function(p) { return p.type === 'exec'; });
2477
+
2478
+ var entry = {
2479
+ role: 'assistant',
2480
+ content: textParts.length > 0 ? textParts[textParts.length - 1].content : '',
2481
+ time: lastTime,
2482
+ };
2483
+ if (reasoningText) entry.reasoning = reasoningText;
2484
+ // 有工具调用或多段文本时设置 parts(启用 timeline 渲染)
2485
+ if (hasExecParts || textParts.length > 1) {
2486
+ entry.parts = parts;
2487
+ }
2488
+ // exec_events 兼容旧渲染路径
2489
+ var execParts = parts.filter(function(p) { return p.type === 'exec'; });
2490
+ if (execParts.length > 0) entry.exec_events = execParts.map(function(p) { return p.data; });
2491
+ // [v1.19.3] 设置收集到的所有 agent 文件
2492
+ if (allAgentFiles.length > 0) entry._files = allAgentFiles;
2493
+
2494
+ grouped.push(entry);
2594
2495
  }
2595
2496
 
2596
2497
  return grouped;
@@ -2679,25 +2580,22 @@ function _renderMessagesInner() {
2679
2580
 
2680
2581
  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;');
2681
2582
  const content = renderMarkdown(msg.content);
2682
- // [v1.16.12→17] 渲染图片和文件附件(支持磁盘持久化 file_id
2683
- const attachmentHtml = (() => {
2583
+ // [v1.19.3] 渲染图片和文件附件(支持磁盘持久化 file_id,图片在气泡顶部、文件在底部)
2584
+ const imageAttachmentHtml = (() => {
2684
2585
  let parts = [];
2685
- // User images
2586
+ // User images(用户发送的图片)
2686
2587
  if (isUser && msg.images && msg.images.length > 0) {
2687
2588
  for (let _imgIdx = 0; _imgIdx < msg.images.length; _imgIdx++) {
2688
2589
  const img = msg.images[_imgIdx];
2689
- // Check if we have a file_id (from server) or raw data
2690
2590
  const fileId = img.id;
2691
2591
  let src;
2692
2592
  let hasFallback = false;
2693
2593
  if (fileId) {
2694
2594
  src = '/api/file/' + fileId;
2695
- // [fix] 如果有 base64 回退数据,准备 onerror 处理
2696
2595
  if (img._base64) hasFallback = true;
2697
2596
  } else {
2698
2597
  src = 'data:' + (img.type || 'image/png') + ';base64,' + (img.data || '');
2699
2598
  }
2700
- // [fix] 添加 onerror 回退:file_id 加载失败时使用 base64 数据或显示占位
2701
2599
  let onerror = '';
2702
2600
  if (hasFallback) {
2703
2601
  onerror = ' onerror="this.onerror=null;this.src=\'data:' + escapeHtml(img.type || 'image/png') + ';base64,' + img._base64 + '\'"';
@@ -2707,34 +2605,58 @@ function _renderMessagesInner() {
2707
2605
  parts.push('<div class="msg-image-wrapper"><img src="' + src + '" class="msg-image" loading="lazy" alt="' + escapeHtml(img.name || 'image') + '"' + onerror + ' onclick="openFileViewer(\'' + (fileId || '') + '\', this.src, \'' + escapeHtml(img.name || 'image') + '\')" /></div>');
2708
2606
  }
2709
2607
  }
2710
- // User files
2608
+ // Agent images(agent 通过 file_send 发送的图片文件,type 为 image/*)
2609
+ const agentFiles = (msg._files || []);
2610
+ if (!isUser && agentFiles.length > 0) {
2611
+ for (const f of agentFiles) {
2612
+ const isImage = f.type && f.type.startsWith('image/');
2613
+ if (isImage && f.id) {
2614
+ parts.push('<div class="msg-image-wrapper agent-image"><img src="/api/file/' + f.id + '" class="msg-image" loading="lazy" alt="' + escapeHtml(f.name || 'image') + '" onerror="this.onerror=null;this.style.background=\'var(--bg3)\';this.style.minHeight=\'60px\';this.alt=\'[图片加载失败]\'" onclick="openFileViewer(\'' + f.id + '\', this.src, \'' + escapeHtml(f.name) + '\')" /></div>');
2615
+ }
2616
+ }
2617
+ }
2618
+ return parts.length > 0 ? '<div class="msg-attachments msg-attachments-images">' + parts.join('') + '</div>' : '';
2619
+ })();
2620
+ const fileAttachmentHtml = (() => {
2621
+ let parts = [];
2622
+ // User files(用户发送的非图片文件)
2711
2623
  if (isUser && msg.files && msg.files.length > 0) {
2712
2624
  for (const f of msg.files) {
2713
2625
  const fileId = f.id;
2714
2626
  const sizeStr = f.size ? formatFileSize(f.size) : '';
2715
2627
  const icon = _getFileIcon(f.name || f.type || '');
2716
- parts.push('<div class="msg-file-item" onclick="openFileViewer(\'' + (fileId || '') + '\', \'/api/file/' + (fileId || '') + '\', \'' + escapeHtml(f.name) + '\')" title="点击打开">' +
2628
+ parts.push('<div class="msg-file-item" title="点击预览">' +
2717
2629
  '<span class="msg-file-icon">' + icon + '</span>' +
2718
- '<span class="msg-file-name">' + escapeHtml(f.name) + '</span>' +
2630
+ '<span class="msg-file-info"><span class="msg-file-name">' + escapeHtml(f.name) + '</span>' +
2719
2631
  (sizeStr ? '<span class="msg-file-size">' + sizeStr + '</span>' : '') +
2632
+ '</span>' +
2633
+ '<span class="msg-file-actions">' +
2634
+ '<a class="msg-file-download" href="/api/file/' + (fileId || '') + '/download" download="' + escapeHtml(f.name) + '" title="下载" onclick="event.stopPropagation()">⬇</a>' +
2635
+ '</span>' +
2720
2636
  '</div>');
2721
2637
  }
2722
2638
  }
2723
- // Agent files (v2_file events) — 支持实时流式 _files 和历史加载的 files
2724
- const agentFiles = (msg._files || (msg.files && !isUser ? msg.files : []));
2725
- if (agentFiles.length > 0) {
2639
+ // Agent files (v2_file events) — 支持实时流式 _files 和历史加载的 files(只渲染非图片文件)
2640
+ const agentFiles = (msg._files || []);
2641
+ if (!isUser && agentFiles.length > 0) {
2726
2642
  for (const f of agentFiles) {
2643
+ const isImage = f.type && f.type.startsWith('image/');
2644
+ if (isImage) continue; // 图片已在 imageAttachmentHtml 中渲染
2727
2645
  const fileId = f.id;
2728
2646
  const sizeStr = f.size ? formatFileSize(f.size) : '';
2729
2647
  const icon = _getFileIcon(f.name || f.type || '');
2730
- parts.push('<div class="msg-file-item agent-file" onclick="openFileViewer(\'' + (fileId || '') + '\', \'/api/file/' + (fileId || '') + '\', \'' + escapeHtml(f.name) + '\')" title="点击打开">' +
2648
+ parts.push('<div class="msg-file-item agent-file" title="点击预览">' +
2731
2649
  '<span class="msg-file-icon">' + icon + '</span>' +
2732
- '<span class="msg-file-name">' + escapeHtml(f.name) + '</span>' +
2650
+ '<span class="msg-file-info"><span class="msg-file-name">' + escapeHtml(f.name) + '</span>' +
2733
2651
  (sizeStr ? '<span class="msg-file-size">' + sizeStr + '</span>' : '') +
2652
+ '</span>' +
2653
+ '<span class="msg-file-actions">' +
2654
+ '<a class="msg-file-download" href="/api/file/' + (fileId || '') + '/download" download="' + escapeHtml(f.name) + '" title="下载" onclick="event.stopPropagation()">⬇</a>' +
2655
+ '</span>' +
2734
2656
  '</div>');
2735
2657
  }
2736
2658
  }
2737
- return parts.length > 0 ? '<div class="msg-attachments">' + parts.join('') + '</div>' : '';
2659
+ return parts.length > 0 ? '<div class="msg-attachments msg-attachments-files">' + parts.join('') + '</div>' : '';
2738
2660
  })();
2739
2661
  const thoughtHtml = msg.thought ? (() => {
2740
2662
  const isStreaming = !!msg.streaming;
@@ -2774,7 +2696,7 @@ function _renderMessagesInner() {
2774
2696
  const ttsIndicator = _isSpeakingThis ?
2775
2697
  ' <span class="tts-playing-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/></svg></span>' : '';
2776
2698
 
2777
- // ── Determine rendering mode and streaming indicator ──
2699
+ // ── Determine rendering mode ──
2778
2700
  const hasParts = Array.isArray(msg.parts) && msg.parts.length > 0;
2779
2701
  const hasStreamingText = msg._streamingText && msg._streamingText.trim();
2780
2702
  const anyContent = msg.content || msg._streamingText || hasParts;
@@ -2787,10 +2709,11 @@ function _renderMessagesInner() {
2787
2709
  <span style="font-weight:500">Agent 正在思考...</span>
2788
2710
  </div>` : '';
2789
2711
 
2790
- // ── Timeline rendering: all parts in ONE unified bubble ──
2791
- let timelineHtml = '';
2712
+ // ── Bubble content: timeline (parts) or single text ──
2713
+ var bubbleHtml = '';
2792
2714
  if (hasParts || hasStreamingText) {
2793
- let partsInner = '';
2715
+ // Timeline mode: interleaved text + tool cards
2716
+ var partsInner = '';
2794
2717
  for (const part of (msg.parts || [])) {
2795
2718
  if (part.type === 'text' && part.content.trim()) {
2796
2719
  partsInner += '<div class="timeline-segment">' + renderMarkdown(part.content) + '</div>';
@@ -2807,37 +2730,32 @@ function _renderMessagesInner() {
2807
2730
  partsInner += '<div class="timeline-segment">' + renderMarkdown(msg._streamingText) + _cursor + '</div>';
2808
2731
  }
2809
2732
  if (partsInner) {
2810
- // All parts (text segments + tool calls) wrapped in ONE message-bubble (full width during streaming)
2811
- timelineHtml = '<div class="message-bubble msg-bubble-wrapper"><div class="msg-timeline">' + partsInner + '</div></div>';
2733
+ bubbleHtml = '<div class="message-bubble msg-bubble-wrapper"><div class="msg-timeline">' + partsInner + '</div></div>';
2734
+ }
2735
+ } else {
2736
+ // Single bubble mode: plain text (possibly collapsed)
2737
+ var renderedContent = content;
2738
+ if (!msg.streaming && !isUser && shouldCollapseContent(msg.content)) {
2739
+ renderedContent = '<details class="file-content-collapse"><summary>📄 文件内容处理结果(点击展开)</summary><div class="collapse-body">' + content + '</div></details>';
2740
+ }
2741
+ if (renderedContent) {
2742
+ bubbleHtml = '<div class="message-bubble msg-bubble-wrapper">' + renderedContent + ttsIndicator + '</div>';
2812
2743
  }
2813
2744
  }
2814
2745
 
2815
- // [v1.17] 对历史消息中的长文件内容进行折叠
2816
- const needCollapse = !msg.streaming && !isUser && !hasParts && !hasStreamingText && shouldCollapseContent(msg.content);
2817
- const collapsedContent = needCollapse
2818
- ? '<details class="file-content-collapse"><summary>📄 文件内容处理结果(点击展开)</summary><div class="collapse-body">' + content + '</div></details>'
2819
- : content;
2820
-
2821
- // Backward compat: single bubble for messages without parts (full width)
2822
- const singleBubbleHtml = (!hasParts && !hasStreamingText)
2823
- ? (collapsedContent ? `<div class="message-bubble msg-bubble-wrapper">${collapsedContent}${ttsIndicator}</div>` : '')
2824
- : '';
2825
-
2826
- // ── Task Plan (historical view only — hidden during streaming, shown after completion) ──
2746
+ // ── Task Plan & Finish Reason (V2 structured output, historical only) ──
2827
2747
  var taskPlanHtml = '';
2828
2748
  if (!msg.streaming && msg._v2TaskPlan && msg._v2TaskPlan.trim()) {
2829
2749
  taskPlanHtml = '<div class="v2-task-plan" style="margin-bottom:8px"><div class="v2-task-plan-header" style="font-size:12px;font-weight:600;color:var(--text3);margin-bottom:4px">📋 任务计划</div><div class="v2-task-plan-body">' + renderMarkdown(msg._v2TaskPlan) + '</div></div>';
2830
2750
  }
2831
- // ── Finish Reason(finish=true 时显示完成原因) ──
2832
2751
  var finishReasonHtml = '';
2833
2752
  if (msg._v2FinishReason && msg._v2FinishReason.trim()) {
2834
2753
  finishReasonHtml = '<div class="v2-finish-reason" style="margin-bottom:8px;padding:8px 12px;border-radius:var(--radius-xs);background:rgba(16,185,129,.08);border-left:3px solid #10b981;font-size:13px;color:var(--text2)"><span style="font-weight:600;color:#10b981;margin-right:6px">✅ 完成原因:</span>' + escapeHtml(msg._v2FinishReason.trim()) + '</div>';
2835
2754
  }
2836
- const execEventsHtml = (!isUser && !hasParts && msg.exec_events && msg.exec_events.length > 0)
2837
- ? renderExecEvents(msg.exec_events, i) : '';
2838
- // [v1.15.9] reasoning 块提级:渲染在 message-row 之外,作为 messages-inner 的直接子元素
2839
- // 这样它不受 message-avatar(32px) 挤压,能真正撑满 100% 宽度
2755
+
2756
+ // ── Reasoning block (rendered outside message-row for full width) ──
2840
2757
  if (reasoningHtml) html += reasoningHtml;
2758
+ // ── Message row ──
2841
2759
  html += `
2842
2760
  <div class="message-row ${msg.role}${msg.streaming ? ' streaming' : ''}">
2843
2761
  <div class="message-avatar">${avatar}</div>
@@ -2845,12 +2763,11 @@ function _renderMessagesInner() {
2845
2763
  ${thoughtHtml}
2846
2764
  ${taskPlanHtml}
2847
2765
  ${finishReasonHtml}
2848
- ${timelineHtml}
2849
- ${singleBubbleHtml}
2850
- ${attachmentHtml}
2766
+ ${imageAttachmentHtml}
2767
+ ${bubbleHtml}
2768
+ ${fileAttachmentHtml}
2851
2769
  ${streamingIndicator}
2852
- ${execEventsHtml}
2853
- ${msg.time ? `<div class="message-time">${formatTime(msg.time)}</div>` : ''}
2770
+ ${msg.time ? '<div class="message-time">' + formatTime(msg.time) + '</div>' : ''}
2854
2771
  ${actionBtns}
2855
2772
  </div>
2856
2773
  </div>`;
@@ -3486,13 +3403,13 @@ function initScrollToBottomBtn() {
3486
3403
  }, { passive: true });
3487
3404
  }
3488
3405
 
3489
- function toast(message, type = 'info') {
3406
+ function toast(message, type = 'info', duration = 3000) {
3490
3407
  const container = document.getElementById('toastContainer');
3491
3408
  const el = document.createElement('div');
3492
3409
  el.className = `toast toast-${type}`;
3493
3410
  el.textContent = message;
3494
3411
  container.appendChild(el);
3495
- setTimeout(() => { el.style.opacity = '0'; setTimeout(() => el.remove(), 300); }, 3000);
3412
+ setTimeout(() => { el.style.opacity = '0'; setTimeout(() => el.remove(), 300); }, duration);
3496
3413
  }
3497
3414
 
3498
3415
  // ══════════════════════════════════════════════════════
package/web/ui/index.html CHANGED
@@ -230,9 +230,16 @@ tr:hover{background:var(--surface2)}
230
230
  .toast{left:16px;right:16px;bottom:16px}
231
231
  .agent-card{flex-direction:column;align-items:flex-start}
232
232
  .agent-card .flex.flex-col{flex-direction:row;gap:4px}
233
- /* 技能行移动端允许换行 */
234
- .skill-row{flex-wrap:wrap}
235
- .skill-row .flex{flex-wrap:wrap;gap:4px}
233
+ /* 技能行移动端优化:纵向堆叠,简介区域不受挤压 */
234
+ .skill-row{flex-wrap:wrap;gap:8px;align-items:flex-start;padding:12px}
235
+ .skill-row > div:first-child{display:none}
236
+ .skill-row .skill-info{flex:1 1 100%;min-width:0}
237
+ .skill-row .skill-name{font-size:14px}
238
+ .skill-row .skill-desc{font-size:13px;line-height:1.6}
239
+ .skill-row .flex{flex-wrap:wrap;gap:6px;width:100%;justify-content:flex-start}
240
+ .skill-row .flex .toggle{margin-left:auto}
241
+ .skill-row .flex .tag{font-size:11px}
242
+ .skill-row .flex .badge{font-size:10px}
236
243
  /* 任务表格优化 */
237
244
  .table-wrap table{min-width:auto}
238
245
  .table-wrap td[style*="max-width:300px"]{max-width:150px!important;white-space:normal!important;word-break:break-all}
@@ -1748,16 +1755,23 @@ async function renderSkills(){
1748
1755
  function skillRowHtml(s){
1749
1756
  const isDisabled=s.disabled;
1750
1757
  const paramCount=(s.parameters||[]).length;
1758
+ const isMarkdown=s.skill_type==='markdown';
1759
+ const icon=isMarkdown?'📝':'🔧';
1760
+ const typeBadge=isMarkdown?'<span class="badge badge-purple">Prompt</span>':'<span class="badge badge-green">内置</span>';
1761
+ const paramTag=paramCount>0?`<span class="tag">${paramCount} 参数</span>`:'';
1762
+ const refTag=(s.references||[]).length>0?`<span class="tag">${s.references.length} 参考资料</span>`:'';
1763
+ const tplTag=s.has_templates?'<span class="tag">含模板</span>':'';
1764
+ const scriptTag=s.has_scripts?'<span class="tag">含脚本</span>':'';
1751
1765
  return `<div class="skill-row" data-name="${escHtml(s.name||'')}" data-desc="${escHtml(s.description||'')}">
1752
- <div style="flex-shrink:0;width:36px;text-align:center;font-size:18px">🔧</div>
1766
+ <div style="flex-shrink:0;width:36px;text-align:center;font-size:18px">${icon}</div>
1753
1767
  <div class="skill-info">
1754
1768
  <div class="skill-name">${escHtml(s.name)} ${isDisabled?'<span class="badge badge-red">已禁用</span>':''} ${s.dangerous?'<span class="badge badge-red">⚠ 危险</span>':''}</div>
1755
1769
  <div class="skill-desc">${escHtml(s.description||'暂无描述')}</div>
1756
1770
  </div>
1757
1771
  <div class="flex gap-8 items-center" style="flex-shrink:0">
1758
- <span class="badge badge-green">内置</span>
1772
+ ${typeBadge}
1759
1773
  ${s.category?`<span class="badge badge-blue">${escHtml(s.category)}</span>`:''}
1760
- <span class="tag">${paramCount} 参数</span>
1774
+ ${paramTag}${refTag}${tplTag}${scriptTag}
1761
1775
  <label class="toggle" title="${isDisabled?'启用':'禁用'}"><input type="checkbox" ${!isDisabled?'checked':''} onchange="toggleSkill('${escHtml(s.name)}',this.checked)"><span class="slider"></span></label>
1762
1776
  <button class="btn btn-sm btn-ghost" onclick="viewSkillDetail('${escHtml(s.name)}')">详情</button>
1763
1777
  </div>