myagent-ai 1.23.18 → 1.23.20

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.
@@ -361,6 +361,17 @@ class ToolDispatcher:
361
361
  embed_url = media_url
362
362
  embed_title = embed_title or ("在线音乐" if media_type == "audio" else "在线视频")
363
363
 
364
+ # [v1.23.19] 构建媒体元数据,持久化到 sent_files 以支持历史消息恢复
365
+ _media_meta = {
366
+ "_type": "media", # 标记为媒体类型(区别于文件)
367
+ "media_type": media_type,
368
+ "embed_url": embed_url or "",
369
+ "title": embed_title,
370
+ "original_url": media_url or fallback_link or "",
371
+ }
372
+ if sent_files is not None:
373
+ sent_files.append(_media_meta)
374
+
364
375
  if embed_url and stream_callback:
365
376
  await self._emit_sse("v2_media", {
366
377
  "media_type": media_type,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "myagent-ai",
3
- "version": "1.23.18",
3
+ "version": "1.23.20",
4
4
  "description": "本地桌面端执行型AI助手 - Open Interpreter 风格 | Local Desktop Execution-Oriented AI Assistant",
5
5
  "main": "main.py",
6
6
  "bin": {
package/web/api_server.py CHANGED
@@ -4252,8 +4252,15 @@ window.addEventListener('beforeunload', function() {{
4252
4252
  meta = e.metadata or {}
4253
4253
  if meta.get("images"):
4254
4254
  msg["images"] = meta["images"]
4255
- if meta.get("files"):
4256
- msg["files"] = meta["files"]
4255
+ # [v1.23.19] 分离 _files 和 _media(前端用 msg._files 和 msg._media)
4256
+ _raw_files = meta.get("files") or []
4257
+ if _raw_files:
4258
+ _file_items = [f for f in _raw_files if f.get("_type") != "media"]
4259
+ _media_items = [f for f in _raw_files if f.get("_type") == "media"]
4260
+ if _file_items:
4261
+ msg["_files"] = _file_items
4262
+ if _media_items:
4263
+ msg["_media"] = _media_items
4257
4264
  result.append(msg)
4258
4265
  return web.json_response(result)
4259
4266
 
@@ -4306,8 +4313,15 @@ window.addEventListener('beforeunload', function() {{
4306
4313
  meta = e.metadata or {}
4307
4314
  if meta.get("images"):
4308
4315
  msg["images"] = meta["images"]
4309
- if meta.get("files"):
4310
- msg["files"] = meta["files"]
4316
+ # [v1.23.19] 分离 _files 和 _media(前端用 msg._files 和 msg._media)
4317
+ _raw_files = meta.get("files") or []
4318
+ if _raw_files:
4319
+ _file_items = [f for f in _raw_files if f.get("_type") != "media"]
4320
+ _media_items = [f for f in _raw_files if f.get("_type") == "media"]
4321
+ if _file_items:
4322
+ msg["_files"] = _file_items
4323
+ if _media_items:
4324
+ msg["_media"] = _media_items
4311
4325
  result.append(msg)
4312
4326
  return web.json_response(result)
4313
4327
 
@@ -2246,10 +2246,15 @@ async function selectSession(id) {
2246
2246
  if (m.images && m.images.length > 0) {
2247
2247
  mapped.images = m.images;
2248
2248
  }
2249
- if (m.files && m.files.length > 0) {
2250
- mapped.files = m.files;
2251
- // [v1.23.13] 同时映射到 _files,使历史消息中的文件卡片可渲染
2252
- mapped._files = m.files;
2249
+ // [v1.23.19] 兼容旧格式(m.files)和新格式(m._files / m._media)
2250
+ var _rawFiles = m._files || m.files || [];
2251
+ if (_rawFiles.length > 0) {
2252
+ mapped.files = _rawFiles;
2253
+ mapped._files = _rawFiles;
2254
+ }
2255
+ // [v1.23.19] 在线媒体嵌入
2256
+ if (m._media && m._media.length > 0) {
2257
+ mapped._media = m._media;
2253
2258
  }
2254
2259
  return mapped;
2255
2260
  });
@@ -2625,6 +2630,8 @@ function groupHistoryMessages(messages) {
2625
2630
  var firstMsg = msg;
2626
2631
  // [v1.19.3] 收集组内所有消息的文件(agent 通过 file_send 发送的文件存在 metadata.files 中)
2627
2632
  var allAgentFiles = [];
2633
+ // [v1.23.19] 收集组内所有消息的在线媒体嵌入
2634
+ var allAgentMedia = [];
2628
2635
 
2629
2636
  while (i < messages.length && messages[i].role !== 'user') {
2630
2637
  var m = messages[i];
@@ -2643,6 +2650,12 @@ function groupHistoryMessages(messages) {
2643
2650
  allAgentFiles.push(m._files[_fi]);
2644
2651
  }
2645
2652
  }
2653
+ // [v1.23.19] 收集后端持久化的 _media 数据
2654
+ if (m._media && Array.isArray(m._media) && m._media.length > 0) {
2655
+ for (var _mi = 0; _mi < m._media.length; _mi++) {
2656
+ allAgentMedia.push(m._media[_mi]);
2657
+ }
2658
+ }
2646
2659
 
2647
2660
  if (mkey === 'reasoning') {
2648
2661
  reasoningText = reasoningText ? (reasoningText + '\n\n' + (m.content || '')) : (m.content || '');
@@ -2677,6 +2690,10 @@ function groupHistoryMessages(messages) {
2677
2690
  if (allAgentFiles.length > 0) entry._files = allAgentFiles;
2678
2691
  // [v1.20.3] 从 playaudio/playvideo 工具调用中重建 _media 数据(历史回放支持)
2679
2692
  var mediaEmbeds = [];
2693
+ // [v1.23.19] 先收集后端持久化的 _media 数据
2694
+ for (var _pmi = 0; _pmi < allAgentMedia.length; _pmi++) {
2695
+ mediaEmbeds.push(allAgentMedia[_pmi]);
2696
+ }
2680
2697
  for (var ei = 0; ei < parts.length; ei++) {
2681
2698
  var ep = parts[ei];
2682
2699
  if (ep.type === 'exec' && ep.data.tool_name && (ep.data.tool_name === 'playaudio' || ep.data.tool_name === 'playvideo')) {
@@ -2712,6 +2729,295 @@ function groupHistoryMessages(messages) {
2712
2729
  }
2713
2730
 
2714
2731
  // ── Messages ──
2732
+ // ── [v1.23.20] 统一消息渲染函数:历史消息和流式消息共享 ──
2733
+ // 生成单条消息的完整 HTML(message-row 外壳 + 所有内部内容)
2734
+ // 流式结束后也用此函数重建 DOM,确保历史/流式样式完全一致
2735
+ window.buildMessageHtml = function(msg, idx, agent) {
2736
+ const isUser = msg.role === 'user';
2737
+ if (msg.role === 'tool') return '';
2738
+
2739
+ const avatar = isUser ? '<span style="font-size:18px">👤</span>' : avatarHtml(agent, 32, 'border-radius:8px;');
2740
+ const content = renderMarkdown(msg.content);
2741
+
2742
+ // ── 图片/音视频附件 ──
2743
+ const imageAttachmentHtml = (() => {
2744
+ let parts = [];
2745
+ if (isUser && msg.images && msg.images.length > 0) {
2746
+ for (let _imgIdx = 0; _imgIdx < msg.images.length; _imgIdx++) {
2747
+ const img = msg.images[_imgIdx];
2748
+ const fileId = img.id;
2749
+ let src;
2750
+ let hasFallback = false;
2751
+ if (fileId) {
2752
+ src = '/api/file/' + fileId;
2753
+ if (img._base64) hasFallback = true;
2754
+ } else {
2755
+ src = 'data:' + (img.type || 'image/png') + ';base64,' + (img.data || '');
2756
+ }
2757
+ let onerror = '';
2758
+ if (hasFallback) {
2759
+ onerror = " onerror=\"this.onerror=null;this.src='data:" + escapeHtml(img.type || 'image/png') + ";base64," + img._base64 + "'\"";
2760
+ } else if (fileId) {
2761
+ onerror = " onerror=\"this.onerror=null;this.style.background='var(--bg3)';this.style.minHeight='60px';this.alt='[图片加载失败]'\"";
2762
+ }
2763
+ 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>");
2764
+ }
2765
+ }
2766
+ const agentFiles = (msg._files || []);
2767
+ if (!isUser && agentFiles.length > 0) {
2768
+ for (const f of agentFiles) {
2769
+ const isImage = f.type && f.type.startsWith('image/');
2770
+ const isAudio = f.type && f.type.startsWith('audio/');
2771
+ const isVideo = f.type && f.type.startsWith('video/');
2772
+ if (isImage && f.id) {
2773
+ 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 || 'image') + '\')" /></div>');
2774
+ }
2775
+ if (isAudio && f.id) {
2776
+ parts.push('<div class="msg-media-player"><audio controls src="/api/file/' + f.id + '" style="width:100%;max-width:480px" preload="metadata" onplay="if(typeof muteTTS===\'function\')muteTTS()" onended="if(typeof unmuteTTS===\'function\')unmuteTTS()"></audio><div class="msg-media-title">' + escapeHtml(f.name || '音频') + '</div></div>');
2777
+ }
2778
+ if (isVideo && f.id) {
2779
+ parts.push('<div class="msg-media-player"><video controls src="/api/file/' + f.id + '" style="width:100%;max-width:640px;border-radius:8px" preload="metadata" onplay="if(typeof muteTTS===\'function\')muteTTS()" onended="if(typeof unmuteTTS===\'function\')unmuteTTS()"></video><div class="msg-media-title">' + escapeHtml(f.name || '视频') + '</div></div>');
2780
+ }
2781
+ }
2782
+ }
2783
+ return parts.length > 0 ? '<div class="msg-attachments msg-attachments-images">' + parts.join('') + '</div>' : '';
2784
+ })();
2785
+
2786
+ // ── 在线媒体嵌入(YouTube/B站/网易云等) ──
2787
+ const mediaEmbedHtml = (() => {
2788
+ const mediaList = (msg._media || []);
2789
+ if (!mediaList || mediaList.length === 0) return '';
2790
+ let parts = [];
2791
+ for (const m of mediaList) {
2792
+ const isAudio = m.media_type === 'audio';
2793
+ let embedUrl = m.embed_url || '';
2794
+ const title = m.title || (isAudio ? '在线音乐' : '在线视频');
2795
+ const origUrl = m.original_url || embedUrl;
2796
+ if (!embedUrl && !origUrl) continue;
2797
+ if (!embedUrl) {
2798
+ const icon = isAudio ? '🎵' : '🎬';
2799
+ parts.push('<div class="msg-media-embed msg-media-link-card" style="padding:10px 14px;border-radius:8px;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.1);cursor:pointer" onclick="window.open(\'' + escapeHtml(origUrl) + '\',\'_blank\')">' +
2800
+ '<div class="msg-media-header"><span class="msg-media-icon">' + icon + '</span><span class="msg-media-label">' + escapeHtml(title) + '</span>' +
2801
+ '<span style="margin-left:auto;font-size:12px;opacity:0.6">点击在新窗口打开 ↗</span></div>' +
2802
+ '<div style="font-size:12px;opacity:0.5;margin-top:4px;word-break:break-all">' + escapeHtml(origUrl) + '</div>' +
2803
+ '</div>');
2804
+ continue;
2805
+ }
2806
+ // URL → embed URL 转换
2807
+ if (embedUrl && !embedUrl.includes('/embed/') && !embedUrl.includes('/player') && !embedUrl.includes('/outchain/')) {
2808
+ const ytMatch = embedUrl.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([\w-]+)/);
2809
+ if (ytMatch) { embedUrl = 'https://www.youtube.com/embed/' + ytMatch[1]; }
2810
+ const biliMatch = embedUrl.match(/bilibili\.com\/video\/(BV[\w]+)/);
2811
+ if (biliMatch) { embedUrl = 'https://player.bilibili.com/player.html?bvid=' + biliMatch[1] + '&autoplay=0'; }
2812
+ const neteaseMatch = embedUrl.match(/music\.163\.com.*[?&]id=(\d+)/);
2813
+ if (neteaseMatch) { embedUrl = 'https://music.163.com/outchain/player?type=2&id=' + neteaseMatch[1] + '&auto=0&height=66'; }
2814
+ const ymMatch = embedUrl.match(/music\.youtube\.com.*list=([\w-]+)/);
2815
+ if (ymMatch) { embedUrl = 'https://music.youtube.com/embed?list=' + ymMatch[1] + '&layout=full'; }
2816
+ }
2817
+ if (isAudio) {
2818
+ var _knownAudioEmbed = embedUrl && (
2819
+ embedUrl.indexOf('music.163.com/outchain') >= 0 ||
2820
+ embedUrl.indexOf('music.youtube.com/embed') >= 0 ||
2821
+ embedUrl.indexOf('player.bilibili.com') >= 0
2822
+ );
2823
+ var _audioPlayerHtml;
2824
+ if (_knownAudioEmbed) {
2825
+ _audioPlayerHtml = '<iframe src="' + escapeHtml(embedUrl) + '" style="width:100%;max-width:480px;height:80px;border:none;border-radius:8px" loading="lazy" allow="autoplay" sandbox="allow-scripts allow-same-origin allow-presentation"></iframe>';
2826
+ } else {
2827
+ _audioPlayerHtml = '<audio controls src="' + escapeHtml(origUrl) + '" style="width:100%;max-width:480px;border-radius:8px" preload="metadata" onplay="if(typeof muteTTS===\'function\')muteTTS()" onended="if(typeof unmuteTTS===\'function\')unmuteTTS()"></audio>' +
2828
+ '<div style="font-size:11px;color:var(--text3);margin-top:4px">部分平台可能因跨域限制无法直接播放,可点击右上角 ↗ 在新窗口打开</div>';
2829
+ }
2830
+ parts.push('<div class="msg-media-embed msg-media-audio">' +
2831
+ '<div class="msg-media-header"><span class="msg-media-icon">🎵</span><span class="msg-media-label">' + escapeHtml(title) + '</span>' +
2832
+ '<a class="msg-media-link" href="' + escapeHtml(origUrl) + '" target="_blank" rel="noopener" title="在新窗口打开">↗</a></div>' +
2833
+ _audioPlayerHtml +
2834
+ '</div>');
2835
+ } else {
2836
+ parts.push('<div class="msg-media-embed msg-media-video">' +
2837
+ '<div class="msg-media-header"><span class="msg-media-icon">🎬</span><span class="msg-media-label">' + escapeHtml(title) + '</span>' +
2838
+ '<a class="msg-media-link" href="' + escapeHtml(origUrl) + '" target="_blank" rel="noopener" title="在新窗口打开">↗</a></div>' +
2839
+ '<iframe src="' + escapeHtml(embedUrl) + '" style="width:100%;max-width:640px;height:360px;border:none;border-radius:8px" loading="lazy" allow="autoplay;encrypted-media;picture-in-picture" allowfullscreen></iframe>' +
2840
+ '</div>');
2841
+ }
2842
+ }
2843
+ return parts.length > 0 ? '<div class="msg-attachments msg-attachments-media">' + parts.join('') + '</div>' : '';
2844
+ })();
2845
+
2846
+ // ── Web Control ──
2847
+ const wcHtml = (() => {
2848
+ const wcEvents = (msg._webControl || []);
2849
+ if (!wcEvents || wcEvents.length === 0) return '';
2850
+ let lastEvent = wcEvents[wcEvents.length - 1];
2851
+ let wcAction = lastEvent.action || 'open';
2852
+ let wcSessionId = lastEvent.session_id || '';
2853
+ let wcUrl = lastEvent.url || '';
2854
+ let wcTitle = 'Web Control';
2855
+ let wcIcon = '🌐';
2856
+ let wcDesc = '';
2857
+ if (wcAction === 'open') {
2858
+ wcDesc = wcUrl ? '打开网页: ' + wcUrl : '已打开网页控制面板';
2859
+ } else if (wcAction === 'navigate') {
2860
+ wcDesc = '导航到: ' + wcUrl;
2861
+ } else if (wcAction === 'close') {
2862
+ wcDesc = '已关闭控制面板';
2863
+ wcIcon = '⏹';
2864
+ }
2865
+ return '<div class="msg-wc-card" onclick="if(typeof openWCOverlay===\'function\')openWCOverlay(\'' + escapeHtml(wcSessionId) + '\',\'' + escapeHtml(wcUrl) + '\')" title="点击重新打开">' +
2866
+ '<span class="msg-wc-icon">' + wcIcon + '</span>' +
2867
+ '<span class="msg-wc-info"><span class="msg-wc-title">' + wcTitle + '</span><span class="msg-wc-desc">' + escapeHtml(wcDesc) + '</span></span>' +
2868
+ '<span class="msg-wc-arrow">↗</span>' +
2869
+ '</div>';
2870
+ })();
2871
+
2872
+ // ── 文件附件(非图片/音视频) ──
2873
+ const fileAttachmentHtml = (() => {
2874
+ let parts = [];
2875
+ if (isUser && msg.files && msg.files.length > 0) {
2876
+ for (const f of msg.files) {
2877
+ const fileId = f.id;
2878
+ const sizeStr = f.size ? formatFileSize(f.size) : '';
2879
+ const icon = _getFileIcon(f.name || f.type || '');
2880
+ parts.push('<div class="msg-file-item" title="点击预览">' +
2881
+ '<span class="msg-file-icon">' + icon + '</span>' +
2882
+ '<span class="msg-file-info"><span class="msg-file-name">' + escapeHtml(f.name) + '</span>' +
2883
+ (sizeStr ? '<span class="msg-file-size">' + sizeStr + '</span>' : '') +
2884
+ '</span>' +
2885
+ '<span class="msg-file-actions">' +
2886
+ '<a class="msg-file-download" href="/api/file/' + (fileId || '') + '?name=' + encodeURIComponent(f.name || 'file') + '" download="' + escapeHtml(f.name) + '" title="下载" onclick="event.stopPropagation()">⬇</a>' +
2887
+ '</span>' +
2888
+ '</div>');
2889
+ }
2890
+ }
2891
+ const agentFiles = (msg._files || []);
2892
+ if (!isUser && agentFiles.length > 0) {
2893
+ for (const f of agentFiles) {
2894
+ const isImage = f.type && f.type.startsWith('image/');
2895
+ const isAudio = f.type && f.type.startsWith('audio/');
2896
+ const isVideo = f.type && f.type.startsWith('video/');
2897
+ if (isImage || isAudio || isVideo) continue;
2898
+ const fileId = f.id;
2899
+ const sizeStr = f.size ? formatFileSize(f.size) : '';
2900
+ const icon = _getFileIcon(f.name || f.type || '');
2901
+ parts.push('<div class="msg-file-item agent-file" title="点击预览">' +
2902
+ '<span class="msg-file-icon">' + icon + '</span>' +
2903
+ '<span class="msg-file-info"><span class="msg-file-name">' + escapeHtml(f.name) + '</span>' +
2904
+ (sizeStr ? '<span class="msg-file-size">' + sizeStr + '</span>' : '') +
2905
+ '</span>' +
2906
+ '<span class="msg-file-actions">' +
2907
+ '<a class="msg-file-download" href="/api/file/' + (fileId || '') + '?name=' + encodeURIComponent(f.name || 'file') + '" download="' + escapeHtml(f.name) + '" title="下载" onclick="event.stopPropagation()">⬇</a>' +
2908
+ '</span>' +
2909
+ '</div>');
2910
+ }
2911
+ }
2912
+ return parts.length > 0 ? '<div class="msg-attachments msg-attachments-files">' + parts.join('') + '</div>' : '';
2913
+ })();
2914
+
2915
+ // ── Thought / Reasoning ──
2916
+ const thoughtHtml = msg.thought ? (() => {
2917
+ const isStreaming = !!msg.streaming;
2918
+ return '<details class="thought-block ' + (isStreaming ? 'streaming' : '') + '"' + (isStreaming ? ' open' : '') + '>' +
2919
+ '<summary>' +
2920
+ '<span class="thought-icon">🧠</span>' +
2921
+ '<span class="thought-label">Agent 思考过程</span>' +
2922
+ (isStreaming ? '<span class="thought-badge">思考中...</span>' : '<span class="thought-badge">已完成</span>') +
2923
+ '</summary>' +
2924
+ '<div class="thought-content">' + renderMarkdown(msg.thought) + '</div>' +
2925
+ '</details>';
2926
+ })() : '';
2927
+ const reasoningHtml = msg.reasoning ? (() => {
2928
+ const isStreaming = !!msg.streaming;
2929
+ return '<details class="thought-block ' + (isStreaming ? 'streaming' : '') + '"' + (isStreaming ? ' open' : '') + '>' +
2930
+ '<summary>' +
2931
+ '<span class="thought-icon">💡</span>' +
2932
+ '<span class="thought-label">模型推理过程</span>' +
2933
+ (isStreaming ? '<span class="thought-badge">推理中...</span>' : '<span class="thought-badge">已完成</span>') +
2934
+ '</summary>' +
2935
+ '<div class="thought-content reasoning-content">' + renderMarkdown(msg.reasoning) + '</div>' +
2936
+ '</details>';
2937
+ })() : '';
2938
+
2939
+ const _isSpeakingThis = ttsManager && ttsManager.isPlaying && ttsManager.currentMsgIndex === idx;
2940
+ const actionBtns = msg.content ? '<div class="msg-actions">' +
2941
+ '<button class="msg-action-btn" onclick="copyMessage(' + idx + ')" title="复制">' +
2942
+ '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>' +
2943
+ '</button>' + (!isUser ?
2944
+ '<button class="msg-action-btn' + (_isSpeakingThis ? ' tts-speaking' : '') + '" onclick="speakMessage(' + idx + ')" title="' + (_isSpeakingThis ? '停止朗读' : '朗读') + '">' +
2945
+ (_isSpeakingThis ?
2946
+ '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/></svg>' :
2947
+ '<svg viewBox="0 0 24 24" width="14" height="14" 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="M19.07 4.93a10 10 0 0 1 0 14.14"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/></svg>') +
2948
+ '</button>' : '') +
2949
+ '</div>' : '';
2950
+ const ttsIndicator = _isSpeakingThis ?
2951
+ ' <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>' : '';
2952
+
2953
+ // ── Bubble content: timeline (parts) or single text ──
2954
+ var bubbleHtml = '';
2955
+ const hasParts = Array.isArray(msg.parts) && msg.parts.length > 0;
2956
+ const hasStreamingText = msg._streamingText && msg._streamingText.trim();
2957
+ const anyContent = msg.content || msg._streamingText || hasParts;
2958
+ const streamingIndicator = msg.streaming && !anyContent && !msg.thought ?
2959
+ '<div class="streaming-indicator"><div class="spinner"></div><div class="streaming-dots"><span class="dot"></span><span class="dot"></span><span class="dot"></span></div><span style="font-weight:500">Agent 正在思考...</span></div>' : '';
2960
+
2961
+ if (hasParts || hasStreamingText) {
2962
+ var partsInner = '';
2963
+ for (const part of (msg.parts || [])) {
2964
+ if (part.type === 'text' && part.content.trim()) {
2965
+ partsInner += '<div class="timeline-segment">' + renderMarkdown(part.content) + '</div>';
2966
+ } else if (part.type === 'exec') {
2967
+ partsInner += renderInlineExecEvent(part.data, idx);
2968
+ } else if (part.type === 'v2_tool') {
2969
+ partsInner += renderInlineExecEvent(part, idx);
2970
+ } else if (part.type === 'v2_ask') {
2971
+ partsInner += '<div class="v2-ask-user"><div class="v2-ask-icon">❓</div><div class="v2-ask-content">' + renderMarkdown(part.data.question) + '</div></div>';
2972
+ }
2973
+ }
2974
+ if (hasStreamingText) {
2975
+ const _cursor = msg.streaming ? '<span class="streaming-cursor"></span>' : '';
2976
+ partsInner += '<div class="timeline-segment">' + renderMarkdown(msg._streamingText) + _cursor + '</div>';
2977
+ }
2978
+ if (partsInner) {
2979
+ bubbleHtml = '<div class="message-bubble msg-bubble-wrapper"><div class="msg-timeline">' + partsInner + '</div></div>';
2980
+ }
2981
+ } else {
2982
+ var renderedContent = content;
2983
+ if (!msg.streaming && !isUser && shouldCollapseContent(msg.content)) {
2984
+ renderedContent = '<details class="file-content-collapse"><summary>📄 文件内容处理结果(点击展开)</summary><div class="collapse-body">' + content + '</div></details>';
2985
+ }
2986
+ if (renderedContent) {
2987
+ bubbleHtml = '<div class="message-bubble msg-bubble-wrapper">' + renderedContent + ttsIndicator + '</div>';
2988
+ }
2989
+ }
2990
+
2991
+ // ── Task Plan & Finish Reason ──
2992
+ var taskPlanHtml = '';
2993
+ if (!msg.streaming && msg._v2TaskPlan && msg._v2TaskPlan.trim()) {
2994
+ 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>';
2995
+ }
2996
+ var finishReasonHtml = '';
2997
+ if (msg._v2FinishReason && msg._v2FinishReason.trim()) {
2998
+ 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>';
2999
+ }
3000
+
3001
+ // ── Assemble full message-row ──
3002
+ return (reasoningHtml ? reasoningHtml : '') +
3003
+ '<div class="message-row ' + msg.role + (msg.streaming ? ' streaming' : '') + '">' +
3004
+ '<div class="message-avatar">' + avatar + '</div>' +
3005
+ '<div class="message-content" style="flex:1;min-width:0;width:100%">' +
3006
+ thoughtHtml +
3007
+ taskPlanHtml +
3008
+ finishReasonHtml +
3009
+ imageAttachmentHtml +
3010
+ mediaEmbedHtml +
3011
+ wcHtml +
3012
+ bubbleHtml +
3013
+ fileAttachmentHtml +
3014
+ streamingIndicator +
3015
+ (msg.time ? '<div class="message-time">' + formatTime(msg.time) + '</div>' : '') +
3016
+ actionBtns +
3017
+ '</div>' +
3018
+ '</div>';
3019
+ };
3020
+
2715
3021
  function renderMessages() {
2716
3022
  try {
2717
3023
  _renderMessagesInner();
@@ -2785,313 +3091,12 @@ function _renderMessagesInner() {
2785
3091
  '<button id="loadMoreBtn" class="btn btn-ghost" onclick="loadMoreMessages()" style="font-size:13px;">' +
2786
3092
  '📜 加载更多历史消息</button></div>';
2787
3093
  }
3094
+ // ── [v1.23.20] 使用统一渲染函数 ──
2788
3095
  for (let i = 0; i < state.messages.length; i++) {
2789
- const msg = state.messages[i];
2790
- const isUser = msg.role === 'user';
2791
-
2792
- // Skip standalone tool messages (now grouped into assistant parts via groupHistoryMessages)
2793
- if (msg.role === 'tool') continue;
2794
-
2795
- const avatar = isUser ? '<span style="font-size:18px">👤</span>' : avatarHtml(agent, 32, 'border-radius:8px;');
2796
- const content = renderMarkdown(msg.content);
2797
- // [v1.19.3] 渲染图片和文件附件(支持磁盘持久化 file_id,图片在气泡顶部、文件在底部)
2798
- const imageAttachmentHtml = (() => {
2799
- let parts = [];
2800
- // User images(用户发送的图片)
2801
- if (isUser && msg.images && msg.images.length > 0) {
2802
- for (let _imgIdx = 0; _imgIdx < msg.images.length; _imgIdx++) {
2803
- const img = msg.images[_imgIdx];
2804
- const fileId = img.id;
2805
- let src;
2806
- let hasFallback = false;
2807
- if (fileId) {
2808
- src = '/api/file/' + fileId;
2809
- if (img._base64) hasFallback = true;
2810
- } else {
2811
- src = 'data:' + (img.type || 'image/png') + ';base64,' + (img.data || '');
2812
- }
2813
- let onerror = '';
2814
- if (hasFallback) {
2815
- onerror = ' onerror="this.onerror=null;this.src=\'data:' + escapeHtml(img.type || 'image/png') + ';base64,' + img._base64 + '\'"';
2816
- } else if (fileId) {
2817
- onerror = ' onerror="this.onerror=null;this.style.background=\'var(--bg3)\';this.style.minHeight=\'60px\';this.alt=\'[图片加载失败]\'"';
2818
- }
2819
- 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>');
2820
- }
2821
- }
2822
- // Agent images(agent 通过 file_send 发送的图片文件,type 为 image/*)
2823
- const agentFiles = (msg._files || []);
2824
- if (!isUser && agentFiles.length > 0) {
2825
- for (const f of agentFiles) {
2826
- const isImage = f.type && f.type.startsWith('image/');
2827
- const isAudio = f.type && f.type.startsWith('audio/');
2828
- const isVideo = f.type && f.type.startsWith('video/');
2829
- if (isImage && f.id) {
2830
- 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>');
2831
- }
2832
- // [v1.20.3] 音频文件渲染为内嵌播放器
2833
- if (isAudio && f.id) {
2834
- parts.push('<div class="msg-media-player"><audio controls src="/api/file/' + f.id + '" style="width:100%;max-width:480px" preload="metadata" onplay="if(typeof muteTTS===\'function\')muteTTS()" onended="if(typeof unmuteTTS===\'function\')unmuteTTS()"></audio><div class="msg-media-title">' + escapeHtml(f.name || '音频') + '</div></div>');
2835
- }
2836
- // [v1.20.3] 视频文件渲染为内嵌播放器
2837
- if (isVideo && f.id) {
2838
- parts.push('<div class="msg-media-player"><video controls src="/api/file/' + f.id + '" style="width:100%;max-width:640px;border-radius:8px" preload="metadata" onplay="if(typeof muteTTS===\'function\')muteTTS()" onended="if(typeof unmuteTTS===\'function\')unmuteTTS()"></video><div class="msg-media-title">' + escapeHtml(f.name || '视频') + '</div></div>');
2839
- }
2840
- }
2841
- }
2842
- return parts.length > 0 ? '<div class="msg-attachments msg-attachments-images">' + parts.join('') + '</div>' : '';
2843
- })();
2844
- // [v1.20.3] 在线媒体嵌入播放器(YouTube/B站/抖音/QQ音乐/网易云等)
2845
- const mediaEmbedHtml = (() => {
2846
- const mediaList = (msg._media || []);
2847
- if (!mediaList || mediaList.length === 0) return '';
2848
- let parts = [];
2849
- for (const m of mediaList) {
2850
- const isAudio = m.media_type === 'audio';
2851
- let embedUrl = m.embed_url || '';
2852
- const title = m.title || (isAudio ? '在线音乐' : '在线视频');
2853
- const origUrl = m.original_url || embedUrl;
2854
- if (!embedUrl && !origUrl) continue;
2855
- // [v1.20.10] 如果 embed_url 为空,渲染链接卡片而非 iframe
2856
- if (!embedUrl) {
2857
- const icon = isAudio ? '🎵' : '🎬';
2858
- parts.push('<div class="msg-media-embed msg-media-link-card" style="padding:10px 14px;border-radius:8px;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.1);cursor:pointer" onclick="window.open(\'' + escapeHtml(origUrl) + '\',\'_blank\')">' +
2859
- '<div class="msg-media-header"><span class="msg-media-icon">' + icon + '</span><span class="msg-media-label">' + escapeHtml(title) + '</span>' +
2860
- '<span style="margin-left:auto;font-size:12px;opacity:0.6">点击在新窗口打开 ↗</span></div>' +
2861
- '<div style="font-size:12px;opacity:0.5;margin-top:4px;word-break:break-all">' + escapeHtml(origUrl) + '</div>' +
2862
- '</div>');
2863
- continue;
2864
- }
2865
- // 前端 URL → 嵌入 URL 转换(历史回放时 embed_url 可能是原始 URL)
2866
- if (embedUrl && !embedUrl.includes('/embed/') && !embedUrl.includes('/player') && !embedUrl.includes('/outchain/')) {
2867
- const ytMatch = embedUrl.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([\w-]+)/);
2868
- if (ytMatch) { embedUrl = 'https://www.youtube.com/embed/' + ytMatch[1]; }
2869
- const biliMatch = embedUrl.match(/bilibili\.com\/video\/(BV[\w]+)/);
2870
- if (biliMatch) { embedUrl = 'https://player.bilibili.com/player.html?bvid=' + biliMatch[1] + '&autoplay=0'; }
2871
- const neteaseMatch = embedUrl.match(/music\.163\.com.*[?&]id=(\d+)/);
2872
- if (neteaseMatch) { embedUrl = 'https://music.163.com/outchain/player?type=2&id=' + neteaseMatch[1] + '&auto=0&height=66'; }
2873
- // [v1.20.10] YouTube Music 播放列表 → embed
2874
- const ymMatch = embedUrl.match(/music\.youtube\.com.*list=([\w-]+)/);
2875
- if (ymMatch) { embedUrl = 'https://music.youtube.com/embed?list=' + ymMatch[1] + '&layout=full'; }
2876
- }
2877
- if (isAudio) {
2878
- // [v1.20.11] 音频播放器:优先使用已知可嵌入平台的 iframe,否则使用原生 <audio> 标签
2879
- // 避免 iframe 加载失败时显示断裂图片图标
2880
- var _knownAudioEmbed = embedUrl && (
2881
- embedUrl.indexOf('music.163.com/outchain') >= 0 ||
2882
- embedUrl.indexOf('music.youtube.com/embed') >= 0 ||
2883
- embedUrl.indexOf('player.bilibili.com') >= 0
2884
- );
2885
- var _audioPlayerHtml;
2886
- if (_knownAudioEmbed) {
2887
- _audioPlayerHtml = '<iframe src="' + escapeHtml(embedUrl) + '" style="width:100%;max-width:480px;height:80px;border:none;border-radius:8px" loading="lazy" allow="autoplay" sandbox="allow-scripts allow-same-origin allow-presentation"></iframe>';
2888
- } else {
2889
- // 不可嵌入的音频链接:使用原生 audio 播放器,避免 iframe 断裂图标
2890
- _audioPlayerHtml = '<audio controls src="' + escapeHtml(origUrl) + '" style="width:100%;max-width:480px;border-radius:8px" preload="metadata" onplay="if(typeof muteTTS===\'function\')muteTTS()" onended="if(typeof unmuteTTS===\'function\')unmuteTTS()"></audio>' +
2891
- '<div style="font-size:11px;color:var(--text3);margin-top:4px">部分平台可能因跨域限制无法直接播放,可点击右上角 ↗ 在新窗口打开</div>';
2892
- }
2893
- parts.push('<div class="msg-media-embed msg-media-audio">' +
2894
- '<div class="msg-media-header"><span class="msg-media-icon">🎵</span><span class="msg-media-label">' + escapeHtml(title) + '</span>' +
2895
- '<a class="msg-media-link" href="' + escapeHtml(origUrl) + '" target="_blank" rel="noopener" title="在新窗口打开">↗</a></div>' +
2896
- _audioPlayerHtml +
2897
- '</div>');
2898
- } else {
2899
- parts.push('<div class="msg-media-embed msg-media-video">' +
2900
- '<div class="msg-media-header"><span class="msg-media-icon">🎬</span><span class="msg-media-label">' + escapeHtml(title) + '</span>' +
2901
- '<a class="msg-media-link" href="' + escapeHtml(origUrl) + '" target="_blank" rel="noopener" title="在新窗口打开">↗</a></div>' +
2902
- '<iframe src="' + escapeHtml(embedUrl) + '" style="width:100%;max-width:640px;height:360px;border:none;border-radius:8px" loading="lazy" allow="autoplay;encrypted-media;picture-in-picture" allowfullscreen></iframe>' +
2903
- '</div>');
2904
- }
2905
- }
2906
- return parts.length > 0 ? '<div class="msg-attachments msg-attachments-media">' + parts.join('') + '</div>' : '';
2907
- })();
2908
-
2909
- // [v1.21.0] Web Control 渲染(显示面板状态卡片)
2910
- const wcHtml = (() => {
2911
- const wcEvents = (msg._webControl || []);
2912
- if (!wcEvents || wcEvents.length === 0) return '';
2913
- let lastEvent = wcEvents[wcEvents.length - 1];
2914
- let wcAction = lastEvent.action || 'open';
2915
- let wcSessionId = lastEvent.session_id || '';
2916
- let wcUrl = lastEvent.url || '';
2917
- let wcTitle = 'Web Control';
2918
- let wcIcon = '🌐';
2919
- let wcDesc = '';
2920
- if (wcAction === 'open') {
2921
- wcDesc = wcUrl ? '打开网页: ' + wcUrl : '已打开网页控制面板';
2922
- } else if (wcAction === 'navigate') {
2923
- wcDesc = '导航到: ' + wcUrl;
2924
- } else if (wcAction === 'close') {
2925
- wcDesc = '已关闭控制面板';
2926
- wcIcon = '⏹';
2927
- }
2928
- return '<div class="msg-wc-card" onclick="if(typeof openWCOverlay===\'function\')openWCOverlay(\'' + escapeHtml(wcSessionId) + '\',\'' + escapeHtml(wcUrl) + '\')" title="点击重新打开">' +
2929
- '<span class="msg-wc-icon">' + wcIcon + '</span>' +
2930
- '<span class="msg-wc-info"><span class="msg-wc-title">' + wcTitle + '</span><span class="msg-wc-desc">' + escapeHtml(wcDesc) + '</span></span>' +
2931
- '<span class="msg-wc-arrow">↗</span>' +
2932
- '</div>';
2933
- })();
2934
-
2935
- const fileAttachmentHtml = (() => {
2936
- let parts = [];
2937
- // User files(用户发送的非图片文件)
2938
- if (isUser && msg.files && msg.files.length > 0) {
2939
- for (const f of msg.files) {
2940
- const fileId = f.id;
2941
- const sizeStr = f.size ? formatFileSize(f.size) : '';
2942
- const icon = _getFileIcon(f.name || f.type || '');
2943
- parts.push('<div class="msg-file-item" title="点击预览">' +
2944
- '<span class="msg-file-icon">' + icon + '</span>' +
2945
- '<span class="msg-file-info"><span class="msg-file-name">' + escapeHtml(f.name) + '</span>' +
2946
- (sizeStr ? '<span class="msg-file-size">' + sizeStr + '</span>' : '') +
2947
- '</span>' +
2948
- '<span class="msg-file-actions">' +
2949
- '<a class="msg-file-download" href="/api/file/' + (fileId || '') + '?name=' + encodeURIComponent(f.name || 'file') + '" download="' + escapeHtml(f.name) + '" title="下载" onclick="event.stopPropagation()">⬇</a>' +
2950
- '</span>' +
2951
- '</div>');
2952
- }
2953
- }
2954
- // Agent files (v2_file events) — 支持实时流式 _files 和历史加载的 files(只渲染非图片/音频/视频文件)
2955
- const agentFiles = (msg._files || []);
2956
- if (!isUser && agentFiles.length > 0) {
2957
- for (const f of agentFiles) {
2958
- const isImage = f.type && f.type.startsWith('image/');
2959
- const isAudio = f.type && f.type.startsWith('audio/');
2960
- const isVideo = f.type && f.type.startsWith('video/');
2961
- if (isImage || isAudio || isVideo) continue; // 图片/音视频已在上方渲染为内嵌播放器
2962
- const fileId = f.id;
2963
- const sizeStr = f.size ? formatFileSize(f.size) : '';
2964
- const icon = _getFileIcon(f.name || f.type || '');
2965
- parts.push('<div class="msg-file-item agent-file" title="点击预览">' +
2966
- '<span class="msg-file-icon">' + icon + '</span>' +
2967
- '<span class="msg-file-info"><span class="msg-file-name">' + escapeHtml(f.name) + '</span>' +
2968
- (sizeStr ? '<span class="msg-file-size">' + sizeStr + '</span>' : '') +
2969
- '</span>' +
2970
- '<span class="msg-file-actions">' +
2971
- '<a class="msg-file-download" href="/api/file/' + (fileId || '') + '?name=' + encodeURIComponent(f.name || 'file') + '" download="' + escapeHtml(f.name) + '" title="下载" onclick="event.stopPropagation()">⬇</a>' +
2972
- '</span>' +
2973
- '</div>');
2974
- }
2975
- }
2976
- return parts.length > 0 ? '<div class="msg-attachments msg-attachments-files">' + parts.join('') + '</div>' : '';
2977
- })();
2978
- const thoughtHtml = msg.thought ? (() => {
2979
- const isStreaming = !!msg.streaming;
2980
- return `<details class="thought-block ${isStreaming ? 'streaming' : ''}" ${isStreaming ? 'open' : ''}>
2981
- <summary>
2982
- <span class="thought-icon">🧠</span>
2983
- <span class="thought-label">Agent 思考过程</span>
2984
- ${isStreaming ? '<span class="thought-badge">思考中...</span>' : '<span class="thought-badge">已完成</span>'}
2985
- </summary>
2986
- <div class="thought-content">${renderMarkdown(msg.thought)}</div>
2987
- </details>`;
2988
- })() : '';
2989
- const reasoningHtml = msg.reasoning ? (() => {
2990
- const isStreaming = !!msg.streaming;
2991
- return `<details class="thought-block ${isStreaming ? 'streaming' : ''}" ${isStreaming ? 'open' : ''}>
2992
- <summary>
2993
- <span class="thought-icon">💡</span>
2994
- <span class="thought-label">模型推理过程</span>
2995
- ${isStreaming ? '<span class="thought-badge">推理中...</span>' : '<span class="thought-badge">已完成</span>'}
2996
- </summary>
2997
- <div class="thought-content reasoning-content">${renderMarkdown(msg.reasoning)}</div>
2998
- </details>`;
2999
- })() : '';
3000
- const _isSpeakingThis = ttsManager && ttsManager.isPlaying && ttsManager.currentMsgIndex === i;
3001
- const actionBtns = msg.content ? `
3002
- <div class="msg-actions">
3003
- <button class="msg-action-btn" onclick="copyMessage(${i})" title="复制">
3004
- <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
3005
- </button>${!isUser ? `
3006
- <button class="msg-action-btn${_isSpeakingThis ? ' tts-speaking' : ''}" onclick="speakMessage(${i})" title="${_isSpeakingThis ? '停止朗读' : '朗读'}">
3007
- ${_isSpeakingThis
3008
- ? '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/></svg>'
3009
- : '<svg viewBox="0 0 24 24" width="14" height="14" 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="M19.07 4.93a10 10 0 0 1 0 14.14"/><path d="M15.54 8.46a5 5 0 0 1 0 7.07"/></svg>'
3010
- }
3011
- </button>` : ''}
3012
- </div>` : '';
3013
- const ttsIndicator = _isSpeakingThis ?
3014
- ' <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>' : '';
3015
-
3016
- // ── Determine rendering mode ──
3017
- const hasParts = Array.isArray(msg.parts) && msg.parts.length > 0;
3018
- const hasStreamingText = msg._streamingText && msg._streamingText.trim();
3019
- const anyContent = msg.content || msg._streamingText || hasParts;
3020
- const streamingIndicator = msg.streaming && !anyContent && !msg.thought ? `
3021
- <div class="streaming-indicator">
3022
- <div class="spinner"></div>
3023
- <div class="streaming-dots">
3024
- <span class="dot"></span><span class="dot"></span><span class="dot"></span>
3025
- </div>
3026
- <span style="font-weight:500">Agent 正在思考...</span>
3027
- </div>` : '';
3028
-
3029
- // ── Bubble content: timeline (parts) or single text ──
3030
- var bubbleHtml = '';
3031
- if (hasParts || hasStreamingText) {
3032
- // Timeline mode: interleaved text + tool cards
3033
- var partsInner = '';
3034
- for (const part of (msg.parts || [])) {
3035
- if (part.type === 'text' && part.content.trim()) {
3036
- partsInner += '<div class="timeline-segment">' + renderMarkdown(part.content) + '</div>';
3037
- } else if (part.type === 'exec') {
3038
- partsInner += renderInlineExecEvent(part.data, i);
3039
- } else if (part.type === 'v2_tool') {
3040
- partsInner += renderInlineExecEvent(part, i);
3041
- } else if (part.type === 'v2_ask') {
3042
- partsInner += '<div class="v2-ask-user"><div class="v2-ask-icon">❓</div><div class="v2-ask-content">' + renderMarkdown(part.data.question) + '</div></div>';
3043
- }
3044
- }
3045
- if (hasStreamingText) {
3046
- const _cursor = msg.streaming ? '<span class="streaming-cursor"></span>' : '';
3047
- partsInner += '<div class="timeline-segment">' + renderMarkdown(msg._streamingText) + _cursor + '</div>';
3048
- }
3049
- if (partsInner) {
3050
- bubbleHtml = '<div class="message-bubble msg-bubble-wrapper"><div class="msg-timeline">' + partsInner + '</div></div>';
3051
- }
3052
- } else {
3053
- // Single bubble mode: plain text (possibly collapsed)
3054
- var renderedContent = content;
3055
- if (!msg.streaming && !isUser && shouldCollapseContent(msg.content)) {
3056
- renderedContent = '<details class="file-content-collapse"><summary>📄 文件内容处理结果(点击展开)</summary><div class="collapse-body">' + content + '</div></details>';
3057
- }
3058
- if (renderedContent) {
3059
- bubbleHtml = '<div class="message-bubble msg-bubble-wrapper">' + renderedContent + ttsIndicator + '</div>';
3060
- }
3061
- }
3062
-
3063
- // ── Task Plan & Finish Reason (V2 structured output, historical only) ──
3064
- var taskPlanHtml = '';
3065
- if (!msg.streaming && msg._v2TaskPlan && msg._v2TaskPlan.trim()) {
3066
- 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>';
3067
- }
3068
- var finishReasonHtml = '';
3069
- if (msg._v2FinishReason && msg._v2FinishReason.trim()) {
3070
- 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>';
3071
- }
3072
-
3073
- // ── Reasoning block (rendered outside message-row for full width) ──
3074
- if (reasoningHtml) html += reasoningHtml;
3075
- // ── Message row ──
3076
- html += `
3077
- <div class="message-row ${msg.role}${msg.streaming ? ' streaming' : ''}">
3078
- <div class="message-avatar">${avatar}</div>
3079
- <div class="message-content" style="flex:1;min-width:0;width:100%">
3080
- ${thoughtHtml}
3081
- ${taskPlanHtml}
3082
- ${finishReasonHtml}
3083
- ${imageAttachmentHtml}
3084
- ${mediaEmbedHtml}
3085
- ${wcHtml}
3086
- ${bubbleHtml}
3087
- ${fileAttachmentHtml}
3088
- ${streamingIndicator}
3089
- ${msg.time ? '<div class="message-time">' + formatTime(msg.time) + '</div>' : ''}
3090
- ${actionBtns}
3091
- </div>
3092
- </div>`;
3096
+ html += buildMessageHtml(state.messages[i], i, agent) || '';
3093
3097
  }
3094
3098
 
3099
+ // Keep welcome card in DOM but hidden
3095
3100
  // Keep welcome card in DOM but hidden
3096
3101
  container.innerHTML = html +
3097
3102
  '<div class="welcome-card" id="welcomeCard" style="display:none"></div>';
@@ -607,28 +607,73 @@ function updateStreamingMessage(msgIdx) {
607
607
  }
608
608
 
609
609
  // ── [v1.23.15] 增量渲染文件卡片(v2_file 事件到达时立即显示) ──
610
+ // [v1.23.20] DOM 结构完全对齐 buildMessageHtml:
611
+ // 图片/音视频 → .msg-attachments.msg-attachments-images(在 bubble 之前)
612
+ // 非媒体文件 → .msg-attachments.msg-attachments-files(在 bubble 之后)
610
613
  if (msg._files && msg._files.length > 0) {
611
- var existingFiles = bubbleWrapper ? bubbleWrapper.querySelectorAll(':scope > .msg-attachments') : contentArea.querySelectorAll(':scope > .msg-attachments');
614
+ // ── 图片/音视频容器(和 buildMessageHtml imageAttachmentHtml 一致) ──
615
+ var imageContainer = contentArea.querySelector(':scope > .msg-attachments-images');
616
+ // ── 非媒体文件容器(和 buildMessageHtml 的 fileAttachmentHtml 一致) ──
617
+ var existingFiles = contentArea.querySelectorAll(':scope > .msg-attachments-files');
612
618
  var fileContainer = existingFiles.length > 0 ? existingFiles[0] : null;
613
- if (!fileContainer) {
614
- fileContainer = document.createElement('div');
615
- fileContainer.className = 'msg-attachments msg-attachments-files';
616
- if (bubbleWrapper) {
617
- bubbleWrapper.appendChild(fileContainer);
618
- } else {
619
- contentArea.appendChild(fileContainer);
620
- }
621
- }
619
+
622
620
  // 只添加未渲染的新文件
623
- var renderedIds = fileContainer._renderedFileIds || [];
621
+ var renderedIds = (fileContainer ? fileContainer._renderedFileIds : []) || [];
622
+ var renderedImageIds = (imageContainer ? imageContainer._renderedImageIds : []) || [];
624
623
  for (var _fIdx = 0; _fIdx < msg._files.length; _fIdx++) {
625
624
  var _f = msg._files[_fIdx];
626
625
  var _fId = _f.id || _f.file_id || '';
627
- if (renderedIds.indexOf(_fId) >= 0) continue;
626
+ // 同时检查两个容器的已渲染列表
627
+ if (renderedIds.indexOf(_fId) >= 0 || renderedImageIds.indexOf(_fId) >= 0) continue;
628
628
  var _isImg = _f.type && _f.type.indexOf('image/') === 0;
629
629
  var _isAud = _f.type && _f.type.indexOf('audio/') === 0;
630
630
  var _isVid = _f.type && _f.type.indexOf('video/') === 0;
631
- if (_isImg || _isAud || _isVid) { renderedIds.push(_fId); continue; }
631
+
632
+ if (_isImg || _isAud || _isVid) {
633
+ // 图片/音频/视频 → imageContainer(和 buildMessageHtml 的 imageAttachmentHtml 一致)
634
+ if (!imageContainer) {
635
+ imageContainer = document.createElement('div');
636
+ imageContainer.className = 'msg-attachments msg-attachments-images';
637
+ imageContainer._renderedImageIds = [];
638
+ // 插入到 bubbleWrapper 之前(和 buildMessageHtml 顺序一致:images 先,media 后,bubble 后)
639
+ if (bubbleWrapper && bubbleWrapper.parentNode) {
640
+ bubbleWrapper.parentNode.insertBefore(imageContainer, bubbleWrapper);
641
+ } else {
642
+ contentArea.appendChild(imageContainer);
643
+ }
644
+ }
645
+ if (_isImg && _fId) {
646
+ var _imgDiv = document.createElement('div');
647
+ _imgDiv.className = 'msg-image-wrapper agent-image';
648
+ _imgDiv.innerHTML = '<img src="/api/file/' + _fId + '" 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(\'' + _fId + '\', this.src, \'' + escapeHtml(_f.name) + '\')" />';
649
+ imageContainer.appendChild(_imgDiv);
650
+ } else if (_isAud && _fId) {
651
+ var _audDiv = document.createElement('div');
652
+ _audDiv.className = 'msg-media-player';
653
+ _audDiv.innerHTML = '<audio controls src="/api/file/' + _fId + '" style="width:100%;max-width:480px" preload="metadata" onplay="if(typeof muteTTS===\'function\')muteTTS()" onended="if(typeof unmuteTTS===\'function\')unmuteTTS()"></audio><div class="msg-media-title">' + escapeHtml(_f.name || '音频') + '</div>';
654
+ imageContainer.appendChild(_audDiv);
655
+ } else if (_isVid && _fId) {
656
+ var _vidDiv = document.createElement('div');
657
+ _vidDiv.className = 'msg-media-player';
658
+ _vidDiv.innerHTML = '<video controls src="/api/file/' + _fId + '" style="width:100%;max-width:640px;border-radius:8px" preload="metadata" onplay="if(typeof muteTTS===\'function\')muteTTS()" onended="if(typeof unmuteTTS===\'function\')unmuteTTS()"></video><div class="msg-media-title">' + escapeHtml(_f.name || '视频') + '</div>';
659
+ imageContainer.appendChild(_vidDiv);
660
+ }
661
+ imageContainer._renderedImageIds.push(_fId);
662
+ continue;
663
+ }
664
+
665
+ // 非媒体文件 → fileContainer
666
+ if (!fileContainer) {
667
+ fileContainer = document.createElement('div');
668
+ fileContainer.className = 'msg-attachments msg-attachments-files';
669
+ fileContainer._renderedFileIds = [];
670
+ // 插入到 bubbleWrapper 之后(和 buildMessageHtml 顺序一致)
671
+ if (bubbleWrapper && bubbleWrapper.parentNode) {
672
+ bubbleWrapper.parentNode.insertBefore(fileContainer, bubbleWrapper.nextSibling);
673
+ } else {
674
+ contentArea.appendChild(fileContainer);
675
+ }
676
+ }
632
677
  var _icon = _getFileIcon(_f.name || _f.type || '');
633
678
  var _sizeStr = _f.size ? formatFileSize(_f.size) : '';
634
679
  var _fDiv = document.createElement('div');
@@ -642,9 +687,52 @@ function updateStreamingMessage(msgIdx) {
642
687
  '<a class="msg-file-download" href="/api/file/' + (_fId || '') + '?name=' + encodeURIComponent(_f.name || 'file') + '" download="' + escapeHtml(_f.name) + '" title="下载" onclick="event.stopPropagation()">⬇</a>' +
643
688
  '</span>';
644
689
  fileContainer.appendChild(_fDiv);
645
- renderedIds.push(_fId);
690
+ fileContainer._renderedFileIds.push(_fId);
646
691
  }
647
- fileContainer._renderedFileIds = renderedIds;
692
+ }
693
+
694
+ // ── [v1.23.19] 增量渲染在线媒体嵌入播放器(v2_media 事件) ──
695
+ // [v1.23.20] 媒体容器放在 bubbleWrapper 外部(和 buildMessageHtml 结构一致)
696
+ if (msg._media && msg._media.length > 0) {
697
+ var existingMedia = contentArea.querySelectorAll(':scope > .msg-attachments-media');
698
+ var mediaContainer = existingMedia.length > 0 ? existingMedia[0] : null;
699
+ if (!mediaContainer) {
700
+ mediaContainer = document.createElement('div');
701
+ mediaContainer.className = 'msg-attachments msg-attachments-media';
702
+ // 插入到 bubbleWrapper 之前(和 buildMessageHtml 顺序一致:media 先,bubble 后)
703
+ if (bubbleWrapper && bubbleWrapper.parentNode) {
704
+ bubbleWrapper.parentNode.insertBefore(mediaContainer, bubbleWrapper);
705
+ } else {
706
+ contentArea.appendChild(mediaContainer);
707
+ }
708
+ }
709
+ var renderedMediaUrls = mediaContainer._renderedMediaUrls || [];
710
+ for (var _mIdx = 0; _mIdx < msg._media.length; _mIdx++) {
711
+ var _m = msg._media[_mIdx];
712
+ var _mUrl = _m.embed_url || _m.original_url || '';
713
+ if (!_mUrl || renderedMediaUrls.indexOf(_mUrl) >= 0) continue;
714
+ var _isAud2 = _m.media_type === 'audio';
715
+ var _mTitle = _m.title || (_isAud2 ? '在线音乐' : '在线视频');
716
+ var _mOrig = _m.original_url || _mUrl;
717
+ var _mDiv = document.createElement('div');
718
+ _mDiv.className = 'msg-media-embed' + (_isAud2 ? ' msg-media-audio' : ' msg-media-video');
719
+ if (!_m.embed_url && _mOrig) {
720
+ // 无嵌入 URL,渲染链接卡片
721
+ _mDiv.className += ' msg-media-link-card';
722
+ _mDiv.style.cssText = 'padding:10px 14px;border-radius:8px;background:rgba(255,255,255,0.06);border:1px solid rgba(255,255,255,0.1);cursor:pointer';
723
+ _mDiv.setAttribute('onclick', "window.open('" + _mOrig.replace(/'/g, "\\'") + "','_blank')");
724
+ _mDiv.innerHTML = '<div class="msg-media-header"><span class="msg-media-icon">' + (_isAud2 ? '🎵' : '🎬') + '</span><span class="msg-media-label">' + escapeHtml(_mTitle) + '</span><span style="margin-left:auto;font-size:12px;opacity:0.6">点击在新窗口打开 ↗</span></div><div style="font-size:12px;opacity:0.5;margin-top:4px;word-break:break-all">' + escapeHtml(_mOrig) + '</div>';
725
+ } else if (_isAud2) {
726
+ // 音频嵌入播放器
727
+ _mDiv.innerHTML = '<div class="msg-media-header"><span class="msg-media-icon">🎵</span><span class="msg-media-label">' + escapeHtml(_mTitle) + '</span><a class="msg-media-link" href="' + escapeHtml(_mOrig) + '" target="_blank" rel="noopener" title="在新窗口打开">↗</a></div><iframe src="' + escapeHtml(_mUrl) + '" style="width:100%;max-width:480px;height:80px;border:none;border-radius:8px" loading="lazy" allow="autoplay" sandbox="allow-scripts allow-same-origin allow-presentation"></iframe>';
728
+ } else {
729
+ // 视频嵌入播放器
730
+ _mDiv.innerHTML = '<div class="msg-media-header"><span class="msg-media-icon">🎬</span><span class="msg-media-label">' + escapeHtml(_mTitle) + '</span><a class="msg-media-link" href="' + escapeHtml(_mOrig) + '" target="_blank" rel="noopener" title="在新窗口打开">↗</a></div><iframe src="' + escapeHtml(_mUrl) + '" style="width:100%;max-width:640px;height:360px;border:none;border-radius:8px" loading="lazy" allow="autoplay;encrypted-media;picture-in-picture" allowfullscreen></iframe>';
731
+ }
732
+ mediaContainer.appendChild(_mDiv);
733
+ renderedMediaUrls.push(_mUrl);
734
+ }
735
+ mediaContainer._renderedMediaUrls = renderedMediaUrls;
648
736
  }
649
737
 
650
738
  // Remove exec events panel if present (events are now inline in timeline)
@@ -2050,8 +2138,41 @@ async function sendMessage(opts) {
2050
2138
  // Assemble final content: prefer V2 reasoning/ask text over raw XML
2051
2139
  state.messages[msgIdx].content = _assembleV2Content(state.messages[msgIdx], msgParts);
2052
2140
  }
2053
- // Final render with completion flash animation
2054
- renderMessages();
2141
+ // ── [v1.23.20] 用统一渲染函数重建最后一条消息 DOM(和历史渲染完全一致) ──
2142
+ // 不再调 renderMessages() 全量重建(会丢失流式增量渲染的文件卡片等)
2143
+ var agent = findAgentByPath(state.activeAgent);
2144
+ var lastMsgRow = document.querySelector('#messagesInner .message-row:last-child');
2145
+ if (lastMsgRow && agent && state.messages[msgIdx]) {
2146
+ var newHtml = buildMessageHtml(state.messages[msgIdx], msgIdx, agent);
2147
+ if (newHtml) {
2148
+ // buildMessageHtml 可能返回 reasoningHtml + message-row(两个顶层元素)
2149
+ var wrapper = document.createElement('div');
2150
+ wrapper.innerHTML = newHtml;
2151
+ var children = Array.from(wrapper.children);
2152
+ // 移除旧的 reasoning block(container 直接子元素,在 message-row 之前)
2153
+ var prevSibling = lastMsgRow.previousElementSibling;
2154
+ if (prevSibling && prevSibling.classList && prevSibling.classList.contains('thought-block')) {
2155
+ var label = prevSibling.querySelector('.thought-label');
2156
+ if (label && label.textContent.includes('模型推理过程')) {
2157
+ prevSibling.remove();
2158
+ }
2159
+ }
2160
+ // 替换 message-row
2161
+ if (children.length >= 2) {
2162
+ // 有 reasoning block + message-row
2163
+ lastMsgRow.replaceWith(children[children.length - 1]);
2164
+ // reasoning block 插入到 message-row 之前
2165
+ for (var ci = 0; ci < children.length - 1; ci++) {
2166
+ lastMsgRow.parentNode.insertBefore(children[ci], lastMsgRow);
2167
+ }
2168
+ } else if (children.length === 1) {
2169
+ lastMsgRow.replaceWith(children[0]);
2170
+ }
2171
+ }
2172
+ } else {
2173
+ // Fallback: full re-render (should not normally happen)
2174
+ renderMessages();
2175
+ }
2055
2176
  // Auto-collapse thought blocks after streaming completes (smooth UX)
2056
2177
  setTimeout(function() {
2057
2178
  const thoughtBlocks = document.querySelectorAll('.thought-block:not(.streaming)');