myagent-ai 1.7.2 → 1.7.3

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.7.2",
3
+ "version": "1.7.3",
4
4
  "description": "本地桌面端执行型AI助手 - Open Interpreter 风格 | Local Desktop Execution-Oriented AI Assistant",
5
5
  "main": "main.py",
6
6
  "bin": {
@@ -65,4 +65,4 @@
65
65
  "departments/",
66
66
  "web/"
67
67
  ]
68
- }
68
+ }
package/web/api_server.py CHANGED
@@ -746,29 +746,41 @@ class ApiServer:
746
746
  return ""
747
747
 
748
748
  base_instruction = (
749
- "你当前处于【执行模式】(Execution Mode)。\n"
750
- "1. **复杂度分析**:首先评估任务复杂度。对于简单问候或常见问题,直接回答;对于多步骤任务,【必须】先制定计划。\n"
751
- "2. **强制规则 - 任务列表**:每次回复【必须】包含 ```tasklist``` 代码块,输出 JSON 格式的任务进度列表。先写纯文本分析,再写 tasklist,最后写 action(如有)。\n"
752
- "3. **强制规则 - 单步执行**:每次回复【只能执行一个操作】(一个工具调用或一个代码块)。执行完后等待结果反馈。\n"
749
+ "你当前处于【执行模式】(Execution Mode)。\n\n"
750
+ "## 核心规则\n"
751
+ "1. **任务列表(强制)**:每次回复【必须】包含一个 ```tasklist``` 代码块,内含 JSON 数组格式的任务进度。\n"
752
+ " - 格式:```tasklist\\n[{\"text\": \"步骤描述\", \"status\": \"pending\"}]\\n```\n"
753
+ " - status 可选值:pending(待执行)、running(进行中)、done(已完成)、blocked(受阻)\n"
754
+ " - 首次收到任务时,拆分为多个步骤,全部标记为 pending\n"
755
+ " - 每次执行完一个步骤后,更新对应步骤状态为 done,下一个为 running\n"
756
+ "2. **单步执行(强制)**:每次回复【只能执行一个操作】(一个工具调用、一个代码块或一个技能调用)。\n"
757
+ " - 执行完一个操作后停下来,等待结果反馈后再决定下一步\n"
758
+ " - 不要一次性执行多个操作\n"
759
+ "3. **回复格式**:先写纯文本分析/总结 → 再写 ```tasklist``` 更新进度 → 最后写 ```action``` 执行操作(如有)\n"
753
760
  )
754
761
 
755
762
  # 从内存读取当前任务列表
756
763
  tasks = self._task_list_store.get(agent_path, [])
757
764
  if not tasks:
758
- return base_instruction + "请分析需求后制定计划。"
765
+ return base_instruction + "\n## 当前状态\n暂无任务计划。请先分析用户需求,拆分为具体步骤,然后用 ```tasklist``` 输出计划。"
759
766
 
760
767
  pending = [f" - ⏳ {t['text']}" for t in tasks if t.get("status") in ("pending", "running", "blocked")]
761
768
  done = [f" - ✅ {t['text']}" for t in tasks if t.get("status") == "done"]
762
769
  running = [f" - 🔄 {t['text']}" for t in tasks if t.get("status") == "running"]
763
770
 
764
- context = base_instruction + "\n当前任务进度:\n"
771
+ context = base_instruction + "\n## 当前任务进度\n"
765
772
  if done:
766
773
  context += "已完成:\n" + "\n".join(done) + "\n"
767
774
  if running:
768
775
  context += "进行中:\n" + "\n".join(running) + "\n"
769
776
  if pending:
770
777
  context += "待执行:\n" + "\n".join(pending) + "\n"
771
- context += "\n请在回复中用 ```tasklist``` 更新任务进度(先写文本分析,再写 tasklist,最后写 action)。记住:【每次只能执行一个操作】。"
778
+ context += (
779
+ "\n## 下一步\n"
780
+ "1. 用纯文本简要分析当前进展\n"
781
+ "2. 用 ```tasklist``` 更新任务进度(标记已完成的步骤为 done,标记当前步骤为 running)\n"
782
+ "3. 用 ```action``` 执行下一个待执行步骤(每次只执行一个操作)\n"
783
+ )
772
784
  return context
773
785
 
774
786
  async def handle_chat_page(self, request):
@@ -2613,7 +2625,9 @@ class ApiServer:
2613
2625
  iteration = 0
2614
2626
  # 追踪连续无 action 迭代次数,防止无限重新提示
2615
2627
  _consecutive_no_action = 0
2616
- _MAX_NO_ACTION_RETRIES = 3
2628
+ _MAX_NO_ACTION_RETRIES = 5 # 提高重试次数,给 LLM 更多机会完成剩余任务
2629
+ # ── 追踪所有流式推送的纯文本(用于刷新后恢复) ──
2630
+ _all_streamed_text_parts = [] # 每轮迭代推送的纯文本片段
2617
2631
 
2618
2632
  while iteration < max_iter:
2619
2633
  iteration += 1
@@ -2682,6 +2696,7 @@ class ApiServer:
2682
2696
  text_before = remaining[:marker_pos]
2683
2697
  if text_before.strip():
2684
2698
  await _write_sse({"type": "text_delta", "content": text_before})
2699
+ _all_streamed_text_parts.append(text_before)
2685
2700
  # 跳过整个开始标记(```action 或 ```tasklist),不要只跳到 ```
2686
2701
  st["processed_pos"] += marker_pos + len(f"```{block_type}")
2687
2702
  if block_type == "tasklist":
@@ -2718,7 +2733,9 @@ class ApiServer:
2718
2733
  # 没有找到标记,流式推送(保留末尾可能的部分标记)
2719
2734
  safe_end = len(remaining) - _MAX_HOLD
2720
2735
  if safe_end > 0:
2721
- await _write_sse({"type": "text_delta", "content": remaining[:safe_end]})
2736
+ chunk = remaining[:safe_end]
2737
+ await _write_sse({"type": "text_delta", "content": chunk})
2738
+ _all_streamed_text_parts.append(chunk)
2722
2739
  st["processed_pos"] += safe_end
2723
2740
  remaining = full_text_so_far[st["processed_pos"]:]
2724
2741
  else:
@@ -2790,6 +2807,7 @@ class ApiServer:
2790
2807
  await _stream_text_chunked(remaining, _write_sse, chunk_size=3, delay=0.01)
2791
2808
  else:
2792
2809
  await _write_sse({"type": "text_delta", "content": remaining})
2810
+ _all_streamed_text_parts.append(remaining)
2793
2811
  st["processed_pos"] = len(full_text)
2794
2812
 
2795
2813
  # Call LLM with streaming — tokens are filtered through _text_delta_callback
@@ -3054,10 +3072,19 @@ class ApiServer:
3054
3072
  break
3055
3073
 
3056
3074
  # Save assistant response to memory
3057
- if agent.memory and final_response:
3058
- agent.memory.add_short_term(session_id=session_id, role="assistant", content=final_response)
3059
-
3060
- return final_response
3075
+ # ── 优先使用流式累积文本(包含所有迭代的纯文本),回退到 final_response ──
3076
+ saved_response = final_response
3077
+ if not saved_response and _all_streamed_text_parts:
3078
+ saved_response = "\n\n".join(p for p in _all_streamed_text_parts if p.strip())
3079
+ if not saved_response and content:
3080
+ saved_response = content # 兜底:使用最后一轮的完整输出
3081
+ if agent.memory and saved_response:
3082
+ agent.memory.add_short_term(session_id=session_id, role="assistant", content=saved_response)
3083
+ elif agent.memory:
3084
+ # 即使为空也保存一条,防止刷新后消息丢失
3085
+ agent.memory.add_short_term(session_id=session_id, role="assistant", content="(执行完成,无文本回复)")
3086
+
3087
+ return saved_response or final_response or content or ""
3061
3088
 
3062
3089
  async def _execute_actions_streaming(
3063
3090
  self, agent, action_data: dict, context, write_sse
@@ -1569,6 +1569,20 @@ input,textarea,select{font:inherit}
1569
1569
  .exec-event-result-btn:hover{background:var(--accent-light);color:var(--accent-dark)}
1570
1570
  .exec-event-result-btn svg{width:12px;height:12px}
1571
1571
 
1572
+ /* ── Inline Exec Events (Timeline Interleaved) ── */
1573
+ .msg-timeline{display:flex;flex-direction:column;gap:6px}
1574
+ .inline-exec-event{margin:2px 0;padding:8px 12px;background:var(--bg2);border-left:3px solid var(--border);border-radius:6px;font-size:13px;animation:execEventSlide .3s ease-out}
1575
+ .inline-exec-header{display:flex;align-items:center;gap:6px;margin-bottom:4px}
1576
+ .inline-exec-icon{font-size:14px}
1577
+ .inline-exec-title{font-weight:500;color:var(--text);font-size:12px}
1578
+ .inline-exec-meta{color:var(--text3);font-size:11px;margin-left:auto}
1579
+ .inline-exec-code{background:var(--bg);padding:6px 8px;border-radius:4px;font-family:'SF Mono','Fira Code','Cascadia Code',monospace;font-size:12px;color:var(--text2);margin:4px 0;max-height:100px;overflow:hidden;cursor:pointer;transition:var(--transition);white-space:pre-wrap;word-break:break-all}
1580
+ .inline-exec-code:hover{background:var(--bg3)}
1581
+ .inline-exec-code.expanded{max-height:none}
1582
+ .inline-exec-summary{color:var(--text2);font-size:12px;margin:4px 0}
1583
+ .inline-exec-result-btn{background:none;border:1px solid var(--border);color:var(--text2);font-size:11px;padding:2px 8px;border-radius:4px;cursor:pointer;margin-top:4px;transition:var(--transition)}
1584
+ .inline-exec-result-btn:hover{background:var(--bg2);border-color:var(--accent);color:var(--accent)}
1585
+
1572
1586
  /* ── Execution Result Modal ── */
1573
1587
  .exec-result-modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:1000;display:flex;align-items:center;justify-content:center;animation:fadeIn .15s ease}
1574
1588
  .exec-result-modal{background:var(--bg);border:1px solid var(--border);border-radius:12px;width:min(680px,90vw);max-height:80vh;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,.25);animation:slideUp .2s ease}
@@ -1605,6 +1619,9 @@ input,textarea,select{font:inherit}
1605
1619
  [data-theme="dark"] .exec-result-modal{background:var(--bg2);border-color:var(--border)}
1606
1620
  [data-theme="dark"] .exec-result-modal-body pre{background:#0a0c10;color:#cdd6f4}
1607
1621
  [data-theme="dark"] .exec-result-info-item{background:var(--bg3)}
1622
+ [data-theme="dark"] .inline-exec-event{background:var(--bg3);border-left-color:var(--border)}
1623
+ [data-theme="dark"] .inline-exec-code{background:var(--bg)}
1624
+ [data-theme="dark"] .inline-exec-result-btn:hover{background:var(--bg4)}
1608
1625
 
1609
1626
  .thought-block {
1610
1627
  background: rgba(0, 0, 0, 0.03);
@@ -1680,9 +1680,12 @@ function renderMessages() {
1680
1680
  </div>` : '';
1681
1681
  const ttsIndicator = ttsManager && ttsManager.isPlaying && ttsManager.currentMsgIndex === i ?
1682
1682
  ' <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>' : '';
1683
- const execEventsHtml = (!isUser && msg.exec_events && msg.exec_events.length > 0)
1684
- ? renderExecEvents(msg.exec_events, i) : '';
1685
- const streamingIndicator = msg.streaming && !msg.content && !msg.thought ? `
1683
+
1684
+ // ── Determine rendering mode and streaming indicator ──
1685
+ const hasParts = Array.isArray(msg.parts) && msg.parts.length > 0;
1686
+ const hasStreamingText = msg._streamingText && msg._streamingText.trim();
1687
+ const anyContent = msg.content || msg._streamingText || hasParts;
1688
+ const streamingIndicator = msg.streaming && !anyContent && !msg.thought ? `
1686
1689
  <div class="streaming-indicator">
1687
1690
  <div class="spinner"></div>
1688
1691
  <div class="streaming-dots">
@@ -1690,13 +1693,42 @@ function renderMessages() {
1690
1693
  </div>
1691
1694
  <span style="font-weight:500">Agent 正在思考...</span>
1692
1695
  </div>` : '';
1696
+
1697
+ // ── Timeline rendering for interleaved text + exec events ──
1698
+ let timelineHtml = '';
1699
+ if (hasParts || hasStreamingText) {
1700
+ let partsHtml = '';
1701
+ for (const part of (msg.parts || [])) {
1702
+ if (part.type === 'text' && part.content.trim()) {
1703
+ partsHtml += '<div class="message-bubble">' + renderMarkdown(part.content) + '</div>';
1704
+ } else if (part.type === 'exec') {
1705
+ partsHtml += renderInlineExecEvent(part.data, i);
1706
+ }
1707
+ }
1708
+ if (hasStreamingText) {
1709
+ partsHtml += '<div class="message-bubble">' + renderMarkdown(msg._streamingText) + '</div>';
1710
+ }
1711
+ if (partsHtml) {
1712
+ timelineHtml = '<div class="msg-timeline">' + partsHtml + '</div>';
1713
+ }
1714
+ }
1715
+
1716
+ // Backward compat: single bubble for messages without parts
1717
+ const singleBubbleHtml = (!hasParts && !hasStreamingText)
1718
+ ? ((content || streamingIndicator) ? `<div class="message-bubble">${content}${ttsIndicator}</div>` : '')
1719
+ : '';
1720
+
1721
+ // Exec events panel: only for backward compat (messages without parts loaded from DB)
1722
+ const execEventsHtml = (!isUser && !hasParts && msg.exec_events && msg.exec_events.length > 0)
1723
+ ? renderExecEvents(msg.exec_events, i) : '';
1693
1724
  html += `
1694
1725
  <div class="message-row ${msg.role}">
1695
1726
  <div class="message-avatar">${avatar}</div>
1696
1727
  <div style="flex:1;min-width:0">
1697
1728
  ${reasoningHtml}
1698
1729
  ${thoughtHtml}
1699
- ${content || streamingIndicator ? `<div class="message-bubble">${content}${ttsIndicator}</div>` : ''}
1730
+ ${timelineHtml}
1731
+ ${singleBubbleHtml}
1700
1732
  ${streamingIndicator}
1701
1733
  ${execEventsHtml}
1702
1734
  ${msg.time ? `<div class="message-time">${formatTime(msg.time)}</div>` : ''}
@@ -1,4 +1,3 @@
1
- <string>:27: SyntaxWarning: invalid escape sequence '\s'
2
1
  // ══════════════════════════════════════════════════════
3
2
  // ── Flow Engine: 文本处理流引擎 ──
4
3
  // ── 负责消息发送、SSE 流式处理、大文本检测与分段、
@@ -351,22 +350,87 @@ function updateStreamingMessage(msgIdx) {
351
350
  }
352
351
  }
353
352
 
354
- // Update content bubble
355
- let bubble = contentArea.querySelector('.message-bubble');
356
- const content = renderMarkdown(msg.content);
357
- if (content && !bubble) {
358
- // Create bubble
359
- bubble = document.createElement('div');
360
- bubble.className = 'message-bubble';
361
- contentArea.appendChild(bubble);
362
- }
363
- if (bubble && content) {
364
- bubble.innerHTML = content;
353
+ // Update content - timeline (interleaved text + exec events) or single bubble (backward compat)
354
+ const hasParts = Array.isArray(msg.parts);
355
+ if (hasParts) {
356
+ // ── Timeline rendering for interleaved text + exec events ──
357
+ let timeline = contentArea.querySelector('.msg-timeline');
358
+ if (!timeline) {
359
+ // Remove old single bubble if exists
360
+ const oldBubble = contentArea.querySelector(':scope > .message-bubble');
361
+ if (oldBubble) oldBubble.remove();
362
+ // Create timeline container
363
+ timeline = document.createElement('div');
364
+ timeline.className = 'msg-timeline';
365
+ // Insert after thought blocks or at beginning
366
+ const allThoughts = contentArea.querySelectorAll(':scope > .thought-block');
367
+ if (allThoughts.length > 0) {
368
+ allThoughts[allThoughts.length - 1].insertAdjacentElement('afterend', timeline);
369
+ } else {
370
+ contentArea.appendChild(timeline);
371
+ }
372
+ }
373
+
374
+ // Cache completed parts rendering (only re-render when parts count changes)
375
+ const partsCount = msg.parts.length;
376
+ if (!msg._renderedPartsHtml || msg._lastPartsCount !== partsCount) {
377
+ let html = '';
378
+ for (const part of msg.parts) {
379
+ if (part.type === 'text' && part.content.trim()) {
380
+ html += '<div class="message-bubble">' + renderMarkdown(part.content) + '</div>';
381
+ } else if (part.type === 'exec') {
382
+ html += renderInlineExecEvent(part.data, msgIdx);
383
+ }
384
+ }
385
+ msg._renderedPartsHtml = html;
386
+ msg._lastPartsCount = partsCount;
387
+ }
388
+
389
+ // Build streaming bubble for current in-progress text
390
+ const streamingText = msg._streamingText || '';
391
+ const streamingBubbleHtml = streamingText.trim()
392
+ ? '<div class="message-bubble">' + renderMarkdown(streamingText) + '</div>'
393
+ : '';
394
+
395
+ timeline.innerHTML = msg._renderedPartsHtml + streamingBubbleHtml;
396
+
397
+ // Remove exec events panel if present (events are now inline in timeline)
398
+ const execPanel = contentArea.querySelector('.exec-events-panel');
399
+ if (execPanel) execPanel.remove();
400
+ } else {
401
+ // ── Backward compat: single content bubble + exec events panel ──
402
+ let bubble = contentArea.querySelector('.message-bubble');
403
+ const content = renderMarkdown(msg.content);
404
+ if (content && !bubble) {
405
+ bubble = document.createElement('div');
406
+ bubble.className = 'message-bubble';
407
+ contentArea.appendChild(bubble);
408
+ }
409
+ if (bubble && content) {
410
+ bubble.innerHTML = content;
411
+ }
412
+
413
+ // Exec events panel (only for backward compat messages without parts)
414
+ if (msg.exec_events && msg.exec_events.length > 0) {
415
+ let execPanel = contentArea.querySelector('.exec-events-panel');
416
+ const newExecHtml = renderExecEvents(msg.exec_events, msgIdx);
417
+ if (execPanel) {
418
+ execPanel.outerHTML = newExecHtml;
419
+ } else {
420
+ const timeEl = contentArea.querySelector('.message-time');
421
+ if (timeEl) {
422
+ timeEl.insertAdjacentHTML('beforebegin', newExecHtml);
423
+ } else {
424
+ contentArea.insertAdjacentHTML('beforeend', newExecHtml);
425
+ }
426
+ }
427
+ }
365
428
  }
366
429
 
367
430
  // Update streaming indicator
368
431
  let indicator = contentArea.querySelector('.streaming-indicator');
369
- const streamingIndicator = msg.streaming && !msg.content && !msg.thought ? `
432
+ const anyContent = msg.content || msg._streamingText || (msg.parts && msg.parts.length > 0);
433
+ const streamingIndicator = msg.streaming && !anyContent && !msg.thought ? `
370
434
  <div class="streaming-indicator">
371
435
  <div class="streaming-dots">
372
436
  <span class="dot"></span><span class="dot"></span><span class="dot"></span>
@@ -381,23 +445,6 @@ function updateStreamingMessage(msgIdx) {
381
445
  indicator.remove();
382
446
  }
383
447
 
384
- // Update exec events panel
385
- if (msg.exec_events && msg.exec_events.length > 0) {
386
- let execPanel = contentArea.querySelector('.exec-events-panel');
387
- const newExecHtml = renderExecEvents(msg.exec_events, msgIdx);
388
- if (execPanel) {
389
- execPanel.outerHTML = newExecHtml;
390
- } else {
391
- // Insert before time element or at end
392
- const timeEl = contentArea.querySelector('.message-time');
393
- if (timeEl) {
394
- timeEl.insertAdjacentHTML('beforebegin', newExecHtml);
395
- } else {
396
- contentArea.insertAdjacentHTML('beforeend', newExecHtml);
397
- }
398
- }
399
- }
400
-
401
448
  // Auto-scroll
402
449
  scrollToBottom();
403
450
  }
@@ -508,6 +555,52 @@ function toggleExecEventsPanel(header) {
508
555
  body.classList.toggle('expanded');
509
556
  }
510
557
 
558
+ // ══════════════════════════════════════════════════════
559
+ // ── Inline Exec Event (Timeline Card) ──
560
+ // ══════════════════════════════════════════════════════
561
+
562
+ function renderInlineExecEvent(data, msgIdx) {
563
+ const iconEmoji = getEventIconEmoji(data);
564
+ const title = data.title || (data.tool_name || data.skill_name || '执行事件');
565
+
566
+ // Build meta text
567
+ let metaParts = [];
568
+ if (data.execution_time !== undefined) metaParts.push('耗时 ' + data.execution_time + 's');
569
+ if (data.language) metaParts.push(escapeHtml(data.language));
570
+ if (data.tool_name || data.skill_name) metaParts.push(escapeHtml(data.tool_name || data.skill_name));
571
+ if (data.timed_out) metaParts.push('超时');
572
+ if (data.exit_code !== undefined) metaParts.push('exit: ' + data.exit_code);
573
+ const metaText = metaParts.join(' · ');
574
+
575
+ // Build body content
576
+ let bodyHtml = '';
577
+ // Code preview for code_exec/code_result
578
+ if (data.code_preview && (data.type === 'code_exec' || data.type === 'code_result')) {
579
+ bodyHtml += '<div class="inline-exec-code" onclick="showExecResultModal(' + msgIdx + ', ' + data.id + ')" title="点击查看完整结果">' + escapeHtml(data.code_preview) + '</div>';
580
+ }
581
+ // Summary for tool_result/skill_result
582
+ if (data.summary && (data.type === 'tool_result' || data.type === 'skill_result')) {
583
+ bodyHtml += '<div class="inline-exec-summary">' + escapeHtml(data.summary) + '</div>';
584
+ }
585
+ // Result button for code_result
586
+ if (data.type === 'code_result' && (data.stdout || data.stderr || data.error)) {
587
+ bodyHtml += '<button class="inline-exec-result-btn" onclick="showExecResultModal(' + msgIdx + ', ' + data.id + ')">查看详情</button>';
588
+ }
589
+ // Result button for tool_result/skill_result
590
+ if ((data.type === 'tool_result' || data.type === 'skill_result') && data.result) {
591
+ bodyHtml += '<button class="inline-exec-result-btn" onclick="showToolResultModal(' + msgIdx + ', ' + data.id + ')">查看详情</button>';
592
+ }
593
+
594
+ return '<div class="inline-exec-event">' +
595
+ '<div class="inline-exec-header">' +
596
+ '<span class="inline-exec-icon">' + iconEmoji + '</span>' +
597
+ '<span class="inline-exec-title">' + escapeHtml(title) + '</span>' +
598
+ (metaText ? '<span class="inline-exec-meta">' + metaText + '</span>' : '') +
599
+ '</div>' +
600
+ bodyHtml +
601
+ '</div>';
602
+ }
603
+
511
604
  // ══════════════════════════════════════════════════════
512
605
  // ── Execution Result Modal (执行结果弹窗) ──
513
606
  // ══════════════════════════════════════════════════════
@@ -703,14 +796,22 @@ async function sendMessage() {
703
796
  const reader = resp.body.getReader();
704
797
  const decoder = new TextDecoder();
705
798
  let buffer = '';
706
- let fullResponse = '';
799
+ let msgParts = []; // Timeline: [{type:'text', content:'...'}, {type:'exec', data:{...}}]
800
+ let currentText = ''; // Accumulator for current streaming text segment
801
+ let allExecEvents = []; // All exec events (for summary panel at bottom)
707
802
  let msgIdx = state.messages.length;
708
803
  let sessionIdReceived = sessionId;
709
- let execEventsReceived = [];
710
804
  let fullThought = '';
711
-
805
+
806
+ function flushCurrentText() {
807
+ if (currentText.trim()) {
808
+ msgParts.push({type: 'text', content: currentText});
809
+ }
810
+ currentText = '';
811
+ }
812
+
712
813
  // Add placeholder for streaming response
713
- state.messages.push({ role: 'assistant', content: '', thought: '', time: new Date().toISOString(), streaming: true });
814
+ state.messages.push({ role: 'assistant', content: '', thought: '', parts: [], time: new Date().toISOString(), streaming: true });
714
815
  renderMessages();
715
816
 
716
817
  while (true) {
@@ -731,13 +832,22 @@ async function sendMessage() {
731
832
  // Sync the actual session ID (backend may prefix with agent_path)
732
833
  state.activeSessionId = evt.session_id;
733
834
  } else if (evt.type === 'text') {
734
- fullResponse = evt.content;
835
+ // Full text event (non-streaming replacement)
836
+ flushCurrentText();
837
+ msgParts.push({type: 'text', content: evt.content});
838
+ state.messages[msgIdx].parts = [...msgParts];
839
+ state.messages[msgIdx]._streamingText = '';
735
840
  state.messages[msgIdx].content = evt.content;
736
841
  renderMessages();
737
842
  } else if (evt.type === 'text_delta') {
738
843
  // Incremental streaming token
739
- fullResponse += evt.content;
740
- state.messages[msgIdx].content = fullResponse;
844
+ currentText += evt.content;
845
+ // Build backward-compat content from all parts + streaming text
846
+ const allText = msgParts.filter(p => p.type === 'text').map(p => p.content).join('\n\n')
847
+ + (currentText.trim() ? '\n\n' + currentText : '');
848
+ state.messages[msgIdx].parts = [...msgParts];
849
+ state.messages[msgIdx]._streamingText = currentText;
850
+ state.messages[msgIdx].content = allText;
741
851
  throttledStreamUpdate(msgIdx);
742
852
  // ── 分段流式 TTS:推送增量文本 ──
743
853
  if (ttsManager.enabled && !ttsManager._streamActive) {
@@ -759,28 +869,39 @@ async function sendMessage() {
759
869
  state.messages[msgIdx].thought = fullThought;
760
870
  throttledStreamUpdate(msgIdx);
761
871
  } else if (evt.type === 'queue_start') {
762
- // New message starting from queue
872
+ // Finalize previous message
873
+ flushCurrentText();
763
874
  if (state.messages[msgIdx]) {
764
875
  state.messages[msgIdx].streaming = false;
765
- if (execEventsReceived.length > 0) state.messages[msgIdx].exec_events = [...execEventsReceived];
876
+ state.messages[msgIdx].parts = [...msgParts];
877
+ state.messages[msgIdx].content = msgParts.filter(p => p.type === 'text').map(p => p.content).join('\n\n') || '(无回复)';
878
+ state.messages[msgIdx]._streamingText = '';
879
+ if (allExecEvents.length > 0) state.messages[msgIdx].exec_events = [...allExecEvents];
766
880
  }
881
+ // Start new message
767
882
  state.messages.push({ role: 'user', content: evt.message, time: new Date().toISOString() });
768
883
  msgIdx = state.messages.length;
769
- fullResponse = '';
884
+ msgParts = [];
885
+ currentText = '';
886
+ allExecEvents = [];
770
887
  fullThought = '';
771
- execEventsReceived = [];
772
- state.messages.push({ role: 'assistant', content: '', thought: '', time: new Date().toISOString(), streaming: true });
888
+ state.messages.push({ role: 'assistant', content: '', thought: '', parts: [], time: new Date().toISOString(), streaming: true });
773
889
  renderMessages();
774
890
  } else if (evt.type === 'clear_text') {
775
891
  // Clear intermediate text from previous agent loop iterations
776
- fullResponse = '';
777
- state.messages[msgIdx].content = '';
892
+ flushCurrentText();
893
+ state.messages[msgIdx].parts = [...msgParts];
894
+ state.messages[msgIdx]._streamingText = '';
895
+ state.messages[msgIdx].content = msgParts.filter(p => p.type === 'text').map(p => p.content).join('\n\n') || '';
778
896
  throttledStreamUpdate(msgIdx);
779
897
  } else if (evt.type === 'exec_event') {
780
898
  // Real-time execution event (tool call, code exec, skill result, etc.)
781
- execEventsReceived.push(evt.data);
782
- // 立即更新消息的 exec_events 并渲染
783
- state.messages[msgIdx].exec_events = [...execEventsReceived];
899
+ flushCurrentText();
900
+ msgParts.push({type: 'exec', data: evt.data});
901
+ allExecEvents.push(evt.data);
902
+ state.messages[msgIdx].parts = [...msgParts];
903
+ state.messages[msgIdx]._streamingText = '';
904
+ state.messages[msgIdx].exec_events = [...allExecEvents];
784
905
  throttledStreamUpdate(msgIdx);
785
906
  } else if (evt.type === 'task_list_update') {
786
907
  // 任务列表 JSON 直推更新(exec 模式)
@@ -803,11 +924,15 @@ async function sendMessage() {
803
924
  }
804
925
  }
805
926
  } else if (evt.type === 'done') {
927
+ flushCurrentText();
806
928
  // done 事件提供最终事件列表(可能有去重/合并)
807
929
  if (evt.exec_events && evt.exec_events.length > 0) {
808
- execEventsReceived = evt.exec_events;
809
- state.messages[msgIdx].exec_events = [...execEventsReceived];
930
+ allExecEvents = evt.exec_events;
810
931
  }
932
+ state.messages[msgIdx].parts = [...msgParts];
933
+ state.messages[msgIdx]._streamingText = '';
934
+ state.messages[msgIdx].exec_events = [...allExecEvents];
935
+ state.messages[msgIdx].content = msgParts.filter(p => p.type === 'text').map(p => p.content).join('\n\n') || '(无回复)';
811
936
  } else if (evt.type === 'reasoning_delta') {
812
937
  // 模型推理过程增量文本(OpenAI o1/o3/DeepSeek-R1 等推理模型)
813
938
  if (!state.messages[msgIdx].reasoning) state.messages[msgIdx].reasoning = '';
@@ -818,22 +943,25 @@ async function sendMessage() {
818
943
  state.messages[msgIdx].reasoning = evt.content;
819
944
  throttledStreamUpdate(msgIdx);
820
945
  } else if (evt.type === 'error') {
821
- fullResponse = '❌ ' + evt.error;
822
- state.messages[msgIdx].content = fullResponse;
946
+ flushCurrentText();
947
+ currentText = '❌ ' + evt.error;
948
+ msgParts.push({type: 'text', content: currentText});
949
+ state.messages[msgIdx].parts = [...msgParts];
950
+ state.messages[msgIdx]._streamingText = '';
951
+ state.messages[msgIdx].content = msgParts.filter(p => p.type === 'text').map(p => p.content).join('\n\n');
823
952
  }
824
953
  } catch (e) { /* skip malformed */ }
825
954
  }
826
955
  }
827
956
 
828
957
  // Finalize message
958
+ flushCurrentText();
829
959
  if (state.messages[msgIdx]) {
830
960
  state.messages[msgIdx].streaming = false;
831
- if (execEventsReceived.length > 0) {
832
- state.messages[msgIdx].exec_events = execEventsReceived;
833
- }
834
- if (!state.messages[msgIdx].content) {
835
- state.messages[msgIdx].content = '(无回复)';
836
- }
961
+ state.messages[msgIdx].parts = [...msgParts];
962
+ state.messages[msgIdx]._streamingText = '';
963
+ state.messages[msgIdx].exec_events = allExecEvents;
964
+ state.messages[msgIdx].content = msgParts.filter(p => p.type === 'text').map(p => p.content).join('\n\n') || '(无回复)';
837
965
  }
838
966
 
839
967
  // Task list 已通过 SSE task_list_update 事件实时推送,无需再轮询