myagent-ai 1.23.19 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "myagent-ai",
3
- "version": "1.23.19",
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": {
@@ -2729,6 +2729,295 @@ function groupHistoryMessages(messages) {
2729
2729
  }
2730
2730
 
2731
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
+
2732
3021
  function renderMessages() {
2733
3022
  try {
2734
3023
  _renderMessagesInner();
@@ -2802,313 +3091,12 @@ function _renderMessagesInner() {
2802
3091
  '<button id="loadMoreBtn" class="btn btn-ghost" onclick="loadMoreMessages()" style="font-size:13px;">' +
2803
3092
  '📜 加载更多历史消息</button></div>';
2804
3093
  }
3094
+ // ── [v1.23.20] 使用统一渲染函数 ──
2805
3095
  for (let i = 0; i < state.messages.length; i++) {
2806
- const msg = state.messages[i];
2807
- const isUser = msg.role === 'user';
2808
-
2809
- // Skip standalone tool messages (now grouped into assistant parts via groupHistoryMessages)
2810
- if (msg.role === 'tool') continue;
2811
-
2812
- const avatar = isUser ? '<span style="font-size:18px">👤</span>' : avatarHtml(agent, 32, 'border-radius:8px;');
2813
- const content = renderMarkdown(msg.content);
2814
- // [v1.19.3] 渲染图片和文件附件(支持磁盘持久化 file_id,图片在气泡顶部、文件在底部)
2815
- const imageAttachmentHtml = (() => {
2816
- let parts = [];
2817
- // User images(用户发送的图片)
2818
- if (isUser && msg.images && msg.images.length > 0) {
2819
- for (let _imgIdx = 0; _imgIdx < msg.images.length; _imgIdx++) {
2820
- const img = msg.images[_imgIdx];
2821
- const fileId = img.id;
2822
- let src;
2823
- let hasFallback = false;
2824
- if (fileId) {
2825
- src = '/api/file/' + fileId;
2826
- if (img._base64) hasFallback = true;
2827
- } else {
2828
- src = 'data:' + (img.type || 'image/png') + ';base64,' + (img.data || '');
2829
- }
2830
- let onerror = '';
2831
- if (hasFallback) {
2832
- onerror = ' onerror="this.onerror=null;this.src=\'data:' + escapeHtml(img.type || 'image/png') + ';base64,' + img._base64 + '\'"';
2833
- } else if (fileId) {
2834
- onerror = ' onerror="this.onerror=null;this.style.background=\'var(--bg3)\';this.style.minHeight=\'60px\';this.alt=\'[图片加载失败]\'"';
2835
- }
2836
- 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>');
2837
- }
2838
- }
2839
- // Agent images(agent 通过 file_send 发送的图片文件,type 为 image/*)
2840
- const agentFiles = (msg._files || []);
2841
- if (!isUser && agentFiles.length > 0) {
2842
- for (const f of agentFiles) {
2843
- const isImage = f.type && f.type.startsWith('image/');
2844
- const isAudio = f.type && f.type.startsWith('audio/');
2845
- const isVideo = f.type && f.type.startsWith('video/');
2846
- if (isImage && f.id) {
2847
- 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>');
2848
- }
2849
- // [v1.20.3] 音频文件渲染为内嵌播放器
2850
- if (isAudio && f.id) {
2851
- 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>');
2852
- }
2853
- // [v1.20.3] 视频文件渲染为内嵌播放器
2854
- if (isVideo && f.id) {
2855
- 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>');
2856
- }
2857
- }
2858
- }
2859
- return parts.length > 0 ? '<div class="msg-attachments msg-attachments-images">' + parts.join('') + '</div>' : '';
2860
- })();
2861
- // [v1.20.3] 在线媒体嵌入播放器(YouTube/B站/抖音/QQ音乐/网易云等)
2862
- const mediaEmbedHtml = (() => {
2863
- const mediaList = (msg._media || []);
2864
- if (!mediaList || mediaList.length === 0) return '';
2865
- let parts = [];
2866
- for (const m of mediaList) {
2867
- const isAudio = m.media_type === 'audio';
2868
- let embedUrl = m.embed_url || '';
2869
- const title = m.title || (isAudio ? '在线音乐' : '在线视频');
2870
- const origUrl = m.original_url || embedUrl;
2871
- if (!embedUrl && !origUrl) continue;
2872
- // [v1.20.10] 如果 embed_url 为空,渲染链接卡片而非 iframe
2873
- if (!embedUrl) {
2874
- const icon = isAudio ? '🎵' : '🎬';
2875
- 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\')">' +
2876
- '<div class="msg-media-header"><span class="msg-media-icon">' + icon + '</span><span class="msg-media-label">' + escapeHtml(title) + '</span>' +
2877
- '<span style="margin-left:auto;font-size:12px;opacity:0.6">点击在新窗口打开 ↗</span></div>' +
2878
- '<div style="font-size:12px;opacity:0.5;margin-top:4px;word-break:break-all">' + escapeHtml(origUrl) + '</div>' +
2879
- '</div>');
2880
- continue;
2881
- }
2882
- // 前端 URL → 嵌入 URL 转换(历史回放时 embed_url 可能是原始 URL)
2883
- if (embedUrl && !embedUrl.includes('/embed/') && !embedUrl.includes('/player') && !embedUrl.includes('/outchain/')) {
2884
- const ytMatch = embedUrl.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([\w-]+)/);
2885
- if (ytMatch) { embedUrl = 'https://www.youtube.com/embed/' + ytMatch[1]; }
2886
- const biliMatch = embedUrl.match(/bilibili\.com\/video\/(BV[\w]+)/);
2887
- if (biliMatch) { embedUrl = 'https://player.bilibili.com/player.html?bvid=' + biliMatch[1] + '&autoplay=0'; }
2888
- const neteaseMatch = embedUrl.match(/music\.163\.com.*[?&]id=(\d+)/);
2889
- if (neteaseMatch) { embedUrl = 'https://music.163.com/outchain/player?type=2&id=' + neteaseMatch[1] + '&auto=0&height=66'; }
2890
- // [v1.20.10] YouTube Music 播放列表 → embed
2891
- const ymMatch = embedUrl.match(/music\.youtube\.com.*list=([\w-]+)/);
2892
- if (ymMatch) { embedUrl = 'https://music.youtube.com/embed?list=' + ymMatch[1] + '&layout=full'; }
2893
- }
2894
- if (isAudio) {
2895
- // [v1.20.11] 音频播放器:优先使用已知可嵌入平台的 iframe,否则使用原生 <audio> 标签
2896
- // 避免 iframe 加载失败时显示断裂图片图标
2897
- var _knownAudioEmbed = embedUrl && (
2898
- embedUrl.indexOf('music.163.com/outchain') >= 0 ||
2899
- embedUrl.indexOf('music.youtube.com/embed') >= 0 ||
2900
- embedUrl.indexOf('player.bilibili.com') >= 0
2901
- );
2902
- var _audioPlayerHtml;
2903
- if (_knownAudioEmbed) {
2904
- _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>';
2905
- } else {
2906
- // 不可嵌入的音频链接:使用原生 audio 播放器,避免 iframe 断裂图标
2907
- _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>' +
2908
- '<div style="font-size:11px;color:var(--text3);margin-top:4px">部分平台可能因跨域限制无法直接播放,可点击右上角 ↗ 在新窗口打开</div>';
2909
- }
2910
- parts.push('<div class="msg-media-embed msg-media-audio">' +
2911
- '<div class="msg-media-header"><span class="msg-media-icon">🎵</span><span class="msg-media-label">' + escapeHtml(title) + '</span>' +
2912
- '<a class="msg-media-link" href="' + escapeHtml(origUrl) + '" target="_blank" rel="noopener" title="在新窗口打开">↗</a></div>' +
2913
- _audioPlayerHtml +
2914
- '</div>');
2915
- } else {
2916
- parts.push('<div class="msg-media-embed msg-media-video">' +
2917
- '<div class="msg-media-header"><span class="msg-media-icon">🎬</span><span class="msg-media-label">' + escapeHtml(title) + '</span>' +
2918
- '<a class="msg-media-link" href="' + escapeHtml(origUrl) + '" target="_blank" rel="noopener" title="在新窗口打开">↗</a></div>' +
2919
- '<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>' +
2920
- '</div>');
2921
- }
2922
- }
2923
- return parts.length > 0 ? '<div class="msg-attachments msg-attachments-media">' + parts.join('') + '</div>' : '';
2924
- })();
2925
-
2926
- // [v1.21.0] Web Control 渲染(显示面板状态卡片)
2927
- const wcHtml = (() => {
2928
- const wcEvents = (msg._webControl || []);
2929
- if (!wcEvents || wcEvents.length === 0) return '';
2930
- let lastEvent = wcEvents[wcEvents.length - 1];
2931
- let wcAction = lastEvent.action || 'open';
2932
- let wcSessionId = lastEvent.session_id || '';
2933
- let wcUrl = lastEvent.url || '';
2934
- let wcTitle = 'Web Control';
2935
- let wcIcon = '🌐';
2936
- let wcDesc = '';
2937
- if (wcAction === 'open') {
2938
- wcDesc = wcUrl ? '打开网页: ' + wcUrl : '已打开网页控制面板';
2939
- } else if (wcAction === 'navigate') {
2940
- wcDesc = '导航到: ' + wcUrl;
2941
- } else if (wcAction === 'close') {
2942
- wcDesc = '已关闭控制面板';
2943
- wcIcon = '⏹';
2944
- }
2945
- return '<div class="msg-wc-card" onclick="if(typeof openWCOverlay===\'function\')openWCOverlay(\'' + escapeHtml(wcSessionId) + '\',\'' + escapeHtml(wcUrl) + '\')" title="点击重新打开">' +
2946
- '<span class="msg-wc-icon">' + wcIcon + '</span>' +
2947
- '<span class="msg-wc-info"><span class="msg-wc-title">' + wcTitle + '</span><span class="msg-wc-desc">' + escapeHtml(wcDesc) + '</span></span>' +
2948
- '<span class="msg-wc-arrow">↗</span>' +
2949
- '</div>';
2950
- })();
2951
-
2952
- const fileAttachmentHtml = (() => {
2953
- let parts = [];
2954
- // User files(用户发送的非图片文件)
2955
- if (isUser && msg.files && msg.files.length > 0) {
2956
- for (const f of msg.files) {
2957
- const fileId = f.id;
2958
- const sizeStr = f.size ? formatFileSize(f.size) : '';
2959
- const icon = _getFileIcon(f.name || f.type || '');
2960
- parts.push('<div class="msg-file-item" title="点击预览">' +
2961
- '<span class="msg-file-icon">' + icon + '</span>' +
2962
- '<span class="msg-file-info"><span class="msg-file-name">' + escapeHtml(f.name) + '</span>' +
2963
- (sizeStr ? '<span class="msg-file-size">' + sizeStr + '</span>' : '') +
2964
- '</span>' +
2965
- '<span class="msg-file-actions">' +
2966
- '<a class="msg-file-download" href="/api/file/' + (fileId || '') + '?name=' + encodeURIComponent(f.name || 'file') + '" download="' + escapeHtml(f.name) + '" title="下载" onclick="event.stopPropagation()">⬇</a>' +
2967
- '</span>' +
2968
- '</div>');
2969
- }
2970
- }
2971
- // Agent files (v2_file events) — 支持实时流式 _files 和历史加载的 files(只渲染非图片/音频/视频文件)
2972
- const agentFiles = (msg._files || []);
2973
- if (!isUser && agentFiles.length > 0) {
2974
- for (const f of agentFiles) {
2975
- const isImage = f.type && f.type.startsWith('image/');
2976
- const isAudio = f.type && f.type.startsWith('audio/');
2977
- const isVideo = f.type && f.type.startsWith('video/');
2978
- if (isImage || isAudio || isVideo) continue; // 图片/音视频已在上方渲染为内嵌播放器
2979
- const fileId = f.id;
2980
- const sizeStr = f.size ? formatFileSize(f.size) : '';
2981
- const icon = _getFileIcon(f.name || f.type || '');
2982
- parts.push('<div class="msg-file-item agent-file" title="点击预览">' +
2983
- '<span class="msg-file-icon">' + icon + '</span>' +
2984
- '<span class="msg-file-info"><span class="msg-file-name">' + escapeHtml(f.name) + '</span>' +
2985
- (sizeStr ? '<span class="msg-file-size">' + sizeStr + '</span>' : '') +
2986
- '</span>' +
2987
- '<span class="msg-file-actions">' +
2988
- '<a class="msg-file-download" href="/api/file/' + (fileId || '') + '?name=' + encodeURIComponent(f.name || 'file') + '" download="' + escapeHtml(f.name) + '" title="下载" onclick="event.stopPropagation()">⬇</a>' +
2989
- '</span>' +
2990
- '</div>');
2991
- }
2992
- }
2993
- return parts.length > 0 ? '<div class="msg-attachments msg-attachments-files">' + parts.join('') + '</div>' : '';
2994
- })();
2995
- const thoughtHtml = msg.thought ? (() => {
2996
- const isStreaming = !!msg.streaming;
2997
- return `<details class="thought-block ${isStreaming ? 'streaming' : ''}" ${isStreaming ? 'open' : ''}>
2998
- <summary>
2999
- <span class="thought-icon">🧠</span>
3000
- <span class="thought-label">Agent 思考过程</span>
3001
- ${isStreaming ? '<span class="thought-badge">思考中...</span>' : '<span class="thought-badge">已完成</span>'}
3002
- </summary>
3003
- <div class="thought-content">${renderMarkdown(msg.thought)}</div>
3004
- </details>`;
3005
- })() : '';
3006
- const reasoningHtml = msg.reasoning ? (() => {
3007
- const isStreaming = !!msg.streaming;
3008
- return `<details class="thought-block ${isStreaming ? 'streaming' : ''}" ${isStreaming ? 'open' : ''}>
3009
- <summary>
3010
- <span class="thought-icon">💡</span>
3011
- <span class="thought-label">模型推理过程</span>
3012
- ${isStreaming ? '<span class="thought-badge">推理中...</span>' : '<span class="thought-badge">已完成</span>'}
3013
- </summary>
3014
- <div class="thought-content reasoning-content">${renderMarkdown(msg.reasoning)}</div>
3015
- </details>`;
3016
- })() : '';
3017
- const _isSpeakingThis = ttsManager && ttsManager.isPlaying && ttsManager.currentMsgIndex === i;
3018
- const actionBtns = msg.content ? `
3019
- <div class="msg-actions">
3020
- <button class="msg-action-btn" onclick="copyMessage(${i})" title="复制">
3021
- <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>
3022
- </button>${!isUser ? `
3023
- <button class="msg-action-btn${_isSpeakingThis ? ' tts-speaking' : ''}" onclick="speakMessage(${i})" title="${_isSpeakingThis ? '停止朗读' : '朗读'}">
3024
- ${_isSpeakingThis
3025
- ? '<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>'
3026
- : '<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>'
3027
- }
3028
- </button>` : ''}
3029
- </div>` : '';
3030
- const ttsIndicator = _isSpeakingThis ?
3031
- ' <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>' : '';
3032
-
3033
- // ── Determine rendering mode ──
3034
- const hasParts = Array.isArray(msg.parts) && msg.parts.length > 0;
3035
- const hasStreamingText = msg._streamingText && msg._streamingText.trim();
3036
- const anyContent = msg.content || msg._streamingText || hasParts;
3037
- const streamingIndicator = msg.streaming && !anyContent && !msg.thought ? `
3038
- <div class="streaming-indicator">
3039
- <div class="spinner"></div>
3040
- <div class="streaming-dots">
3041
- <span class="dot"></span><span class="dot"></span><span class="dot"></span>
3042
- </div>
3043
- <span style="font-weight:500">Agent 正在思考...</span>
3044
- </div>` : '';
3045
-
3046
- // ── Bubble content: timeline (parts) or single text ──
3047
- var bubbleHtml = '';
3048
- if (hasParts || hasStreamingText) {
3049
- // Timeline mode: interleaved text + tool cards
3050
- var partsInner = '';
3051
- for (const part of (msg.parts || [])) {
3052
- if (part.type === 'text' && part.content.trim()) {
3053
- partsInner += '<div class="timeline-segment">' + renderMarkdown(part.content) + '</div>';
3054
- } else if (part.type === 'exec') {
3055
- partsInner += renderInlineExecEvent(part.data, i);
3056
- } else if (part.type === 'v2_tool') {
3057
- partsInner += renderInlineExecEvent(part, i);
3058
- } else if (part.type === 'v2_ask') {
3059
- partsInner += '<div class="v2-ask-user"><div class="v2-ask-icon">❓</div><div class="v2-ask-content">' + renderMarkdown(part.data.question) + '</div></div>';
3060
- }
3061
- }
3062
- if (hasStreamingText) {
3063
- const _cursor = msg.streaming ? '<span class="streaming-cursor"></span>' : '';
3064
- partsInner += '<div class="timeline-segment">' + renderMarkdown(msg._streamingText) + _cursor + '</div>';
3065
- }
3066
- if (partsInner) {
3067
- bubbleHtml = '<div class="message-bubble msg-bubble-wrapper"><div class="msg-timeline">' + partsInner + '</div></div>';
3068
- }
3069
- } else {
3070
- // Single bubble mode: plain text (possibly collapsed)
3071
- var renderedContent = content;
3072
- if (!msg.streaming && !isUser && shouldCollapseContent(msg.content)) {
3073
- renderedContent = '<details class="file-content-collapse"><summary>📄 文件内容处理结果(点击展开)</summary><div class="collapse-body">' + content + '</div></details>';
3074
- }
3075
- if (renderedContent) {
3076
- bubbleHtml = '<div class="message-bubble msg-bubble-wrapper">' + renderedContent + ttsIndicator + '</div>';
3077
- }
3078
- }
3079
-
3080
- // ── Task Plan & Finish Reason (V2 structured output, historical only) ──
3081
- var taskPlanHtml = '';
3082
- if (!msg.streaming && msg._v2TaskPlan && msg._v2TaskPlan.trim()) {
3083
- 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>';
3084
- }
3085
- var finishReasonHtml = '';
3086
- if (msg._v2FinishReason && msg._v2FinishReason.trim()) {
3087
- 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>';
3088
- }
3089
-
3090
- // ── Reasoning block (rendered outside message-row for full width) ──
3091
- if (reasoningHtml) html += reasoningHtml;
3092
- // ── Message row ──
3093
- html += `
3094
- <div class="message-row ${msg.role}${msg.streaming ? ' streaming' : ''}">
3095
- <div class="message-avatar">${avatar}</div>
3096
- <div class="message-content" style="flex:1;min-width:0;width:100%">
3097
- ${thoughtHtml}
3098
- ${taskPlanHtml}
3099
- ${finishReasonHtml}
3100
- ${imageAttachmentHtml}
3101
- ${mediaEmbedHtml}
3102
- ${wcHtml}
3103
- ${bubbleHtml}
3104
- ${fileAttachmentHtml}
3105
- ${streamingIndicator}
3106
- ${msg.time ? '<div class="message-time">' + formatTime(msg.time) + '</div>' : ''}
3107
- ${actionBtns}
3108
- </div>
3109
- </div>`;
3096
+ html += buildMessageHtml(state.messages[i], i, agent) || '';
3110
3097
  }
3111
3098
 
3099
+ // Keep welcome card in DOM but hidden
3112
3100
  // Keep welcome card in DOM but hidden
3113
3101
  container.innerHTML = html +
3114
3102
  '<div class="welcome-card" id="welcomeCard" style="display:none"></div>';
@@ -607,51 +607,72 @@ 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
- // [v1.23.19] 图片/音频/视频也直接渲染(不再跳过等 renderMessages)
632
- if (_isImg && _fId) {
633
- var _imgDiv = document.createElement('div');
634
- _imgDiv.className = 'msg-image-wrapper agent-image';
635
- _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) + '\')" />';
636
- fileContainer.appendChild(_imgDiv);
637
- renderedIds.push(_fId);
638
- continue;
639
- }
640
- if (_isAud && _fId) {
641
- var _audDiv = document.createElement('div');
642
- _audDiv.className = 'msg-media-player';
643
- _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>';
644
- fileContainer.appendChild(_audDiv);
645
- renderedIds.push(_fId);
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);
646
662
  continue;
647
663
  }
648
- if (_isVid && _fId) {
649
- var _vidDiv = document.createElement('div');
650
- _vidDiv.className = 'msg-media-player';
651
- _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>';
652
- fileContainer.appendChild(_vidDiv);
653
- renderedIds.push(_fId);
654
- continue;
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
+ }
655
676
  }
656
677
  var _icon = _getFileIcon(_f.name || _f.type || '');
657
678
  var _sizeStr = _f.size ? formatFileSize(_f.size) : '';
@@ -666,20 +687,21 @@ function updateStreamingMessage(msgIdx) {
666
687
  '<a class="msg-file-download" href="/api/file/' + (_fId || '') + '?name=' + encodeURIComponent(_f.name || 'file') + '" download="' + escapeHtml(_f.name) + '" title="下载" onclick="event.stopPropagation()">⬇</a>' +
667
688
  '</span>';
668
689
  fileContainer.appendChild(_fDiv);
669
- renderedIds.push(_fId);
690
+ fileContainer._renderedFileIds.push(_fId);
670
691
  }
671
- fileContainer._renderedFileIds = renderedIds;
672
692
  }
673
693
 
674
694
  // ── [v1.23.19] 增量渲染在线媒体嵌入播放器(v2_media 事件) ──
695
+ // [v1.23.20] 媒体容器放在 bubbleWrapper 外部(和 buildMessageHtml 结构一致)
675
696
  if (msg._media && msg._media.length > 0) {
676
- var existingMedia = bubbleWrapper ? bubbleWrapper.querySelectorAll(':scope > .msg-attachments-media') : contentArea.querySelectorAll(':scope > .msg-attachments-media');
697
+ var existingMedia = contentArea.querySelectorAll(':scope > .msg-attachments-media');
677
698
  var mediaContainer = existingMedia.length > 0 ? existingMedia[0] : null;
678
699
  if (!mediaContainer) {
679
700
  mediaContainer = document.createElement('div');
680
701
  mediaContainer.className = 'msg-attachments msg-attachments-media';
681
- if (bubbleWrapper) {
682
- bubbleWrapper.appendChild(mediaContainer);
702
+ // 插入到 bubbleWrapper 之前(和 buildMessageHtml 顺序一致:media 先,bubble 后)
703
+ if (bubbleWrapper && bubbleWrapper.parentNode) {
704
+ bubbleWrapper.parentNode.insertBefore(mediaContainer, bubbleWrapper);
683
705
  } else {
684
706
  contentArea.appendChild(mediaContainer);
685
707
  }
@@ -2116,8 +2138,41 @@ async function sendMessage(opts) {
2116
2138
  // Assemble final content: prefer V2 reasoning/ask text over raw XML
2117
2139
  state.messages[msgIdx].content = _assembleV2Content(state.messages[msgIdx], msgParts);
2118
2140
  }
2119
- // Final render with completion flash animation
2120
- 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
+ }
2121
2176
  // Auto-collapse thought blocks after streaming completes (smooth UX)
2122
2177
  setTimeout(function() {
2123
2178
  const thoughtBlocks = document.querySelectorAll('.thought-block:not(.streaming)');