myagent-ai 1.13.0 → 1.13.2

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.
@@ -454,7 +454,11 @@ class MainAgent(BaseAgent):
454
454
  )
455
455
  if db_history:
456
456
  conversation_history = [
457
- Message(role=entry.role, content=entry.content)
457
+ Message(
458
+ role=entry.role,
459
+ content=entry.content,
460
+ metadata={"time": (entry.created_at[:19] if entry.created_at else "")}
461
+ )
458
462
  for entry in db_history
459
463
  ]
460
464
  logger.info(f"[{task_id}] 从 DB 加载了 {len(conversation_history)} 条历史对话")
@@ -42,6 +42,27 @@ if TYPE_CHECKING:
42
42
  logger = get_logger("myagent.context_builder")
43
43
 
44
44
 
45
+ # ── 知识库 RAG 索引缓存(模块级,避免每次 LLM 调用重建) ──
46
+ _rag_cache: dict = {} # {abs_kb_dir: {"rag": KnowledgeRAG, "mtime": str}}
47
+
48
+
49
+ def _compute_dir_mtime(dir_path: str) -> str:
50
+ """计算目录下所有支持文件的修改时间摘要(用于脏检测)"""
51
+ import os
52
+ _KB_EXTS = {".md", ".txt", ".json", ".csv", ".py", ".js", ".html"}
53
+ mtimes = []
54
+ try:
55
+ for f in sorted(os.listdir(dir_path)):
56
+ fp = os.path.join(dir_path, f)
57
+ if os.path.isfile(fp):
58
+ ext = os.path.splitext(f)[1].lower()
59
+ if ext in _KB_EXTS:
60
+ mtimes.append(f"{f}:{os.path.getmtime(fp)}")
61
+ except OSError:
62
+ pass
63
+ return "|".join(sorted(mtimes))
64
+
65
+
45
66
  # 默认知识库目录名(相对于 data_dir)
46
67
  _DEFAULT_KB_RELATIVE_PATH = "knowledge"
47
68
 
@@ -143,6 +164,9 @@ class ContextBuilder:
143
164
  context_body = "\n".join(sections)
144
165
  context_xml = f"<context>\n{context_body}\n</context>"
145
166
 
167
+ # ── Token 预算检查与自动裁剪 ──
168
+ context_xml = self._enforce_token_budget(context_xml)
169
+
146
170
  logger.debug(
147
171
  f"上下文已构建 (session={session_id}, 对话条数={len(conversation_history)}, "
148
172
  f"context长度={len(context_xml)})"
@@ -336,7 +360,10 @@ class ContextBuilder:
336
360
  return "<knowledge>\n(未找到相关知识)\n</knowledge>"
337
361
 
338
362
  def _search_knowledge_dir(self, kb_dir: str, query: str, top_k: int = 5) -> str:
339
- """在指定知识库目录中执行 RAG 搜索并格式化结果"""
363
+ """在指定知识库目录中执行 RAG 搜索并格式化结果
364
+
365
+ 使用模块级缓存 + 文件修改时间脏检测,避免每次 LLM 调用都重建索引。
366
+ """
340
367
  import os as _os
341
368
 
342
369
  if not query.strip():
@@ -348,8 +375,30 @@ class ContextBuilder:
348
375
  try:
349
376
  from knowledge.rag import KnowledgeRAG
350
377
 
351
- rag = KnowledgeRAG(kb_dir=kb_dir)
352
- rag.build_index()
378
+ # ── 缓存键: 目录绝对路径 ──
379
+ abs_kb = _os.path.abspath(kb_dir)
380
+ cache = _rag_cache.get(abs_kb)
381
+ need_rebuild = True
382
+
383
+ if cache is not None:
384
+ # 脏检测: 比较上次记录的文件修改时间摘要
385
+ current_mtime = _compute_dir_mtime(abs_kb)
386
+ if current_mtime == cache["mtime"]:
387
+ need_rebuild = False
388
+ else:
389
+ logger.debug(f"知识库目录变更检测到 ({abs_kb}),重建索引")
390
+
391
+ if need_rebuild:
392
+ rag = KnowledgeRAG(kb_dir=kb_dir)
393
+ rag.build_index()
394
+ _rag_cache[abs_kb] = {
395
+ "rag": rag,
396
+ "mtime": _compute_dir_mtime(abs_kb),
397
+ }
398
+ rebuild_tag = "重新" if cache else ""
399
+ logger.debug(f"知识库索引已{rebuild_tag}构建: {rag.total_chunks} 块 ({abs_kb})")
400
+ else:
401
+ rag = cache["rag"]
353
402
 
354
403
  if rag.total_chunks == 0:
355
404
  return ""
@@ -415,7 +464,12 @@ class ContextBuilder:
415
464
  if not content.strip():
416
465
  continue
417
466
  label = role_labels.get(role, role)
418
- filtered_msgs.append((label, content.strip()))
467
+ # 从 metadata 中提取时间(DB加载时已附带)
468
+ msg_time = ""
469
+ msg_meta = getattr(msg, "metadata", None)
470
+ if isinstance(msg_meta, dict):
471
+ msg_time = msg_meta.get("time", "")
472
+ filtered_msgs.append((label, content.strip(), msg_time))
419
473
 
420
474
  if not filtered_msgs:
421
475
  return "<resentdialog>\n(无对话历史)\n</resentdialog>"
@@ -439,8 +493,12 @@ class ContextBuilder:
439
493
  formatted_lines.append(prefix_text)
440
494
  formatted_lines.append("") # 空行分隔
441
495
 
442
- for label, content in recent_msgs:
443
- formatted_lines.append(f"[{label}] {_xml_escape(content)}")
496
+ for label, content, msg_time in recent_msgs:
497
+ # 临时合并时间信息到内容中给 LLM 参考
498
+ if msg_time:
499
+ formatted_lines.append(f"[{label}] [{msg_time}] {_xml_escape(content)}")
500
+ else:
501
+ formatted_lines.append(f"[{label}] {_xml_escape(content)}")
444
502
 
445
503
  dialog_text = "\n".join(formatted_lines)
446
504
 
@@ -482,7 +540,9 @@ class ContextBuilder:
482
540
  return ""
483
541
 
484
542
  summary_parts: List[str] = ["[历史对话摘要]"]
485
- for label, content in old_msgs:
543
+ for item in old_msgs:
544
+ label = item[0]
545
+ content = item[1]
486
546
  # 提取第一行或前100字符作为要点
487
547
  first_line = content.split("\n")[0].strip()
488
548
  if len(first_line) > 100:
@@ -637,6 +697,87 @@ class ContextBuilder:
637
697
  lines.append("</tools>")
638
698
  return "\n".join(lines)
639
699
 
700
+ # =========================================================================
701
+ # Token 预算管理
702
+ # =========================================================================
703
+
704
+ def _enforce_token_budget(self, context_xml: str, budget_ratio: float = 0.75) -> str:
705
+ """
706
+ Token 预算检查与自动裁剪。
707
+
708
+ 估算 context_xml 的 token 数,如果超过 budget_ratio * context_window,
709
+ 按优先级裁剪(先裁剪 <knowledge>、<recall_memory>、<automemory>,
710
+ 再裁剪 <resentdialog> 历史部分)。
711
+
712
+ Args:
713
+ context_xml: 完整的 <context> XML 字符串
714
+ budget_ratio: 上下文窗口使用比例上限(默认 75%,为系统提示和输出预留空间)
715
+
716
+ Returns:
717
+ 裁剪后的 context_xml
718
+ """
719
+ if not context_xml:
720
+ return context_xml
721
+
722
+ # 粗略估算 token: 中文约 1.3 token/字,英文约 0.35 token/字
723
+ def _est_tok(text: str) -> int:
724
+ if not text:
725
+ return 0
726
+ cn = sum(1 for c in text if '\u4e00' <= c <= '\u9fff')
727
+ other = len(text) - cn
728
+ return int(cn * 1.3 + other * 0.35)
729
+
730
+ # 默认 128K context window
731
+ window = 128000
732
+
733
+ budget = int(window * budget_ratio)
734
+ estimated = _est_tok(context_xml)
735
+
736
+ if estimated <= budget:
737
+ return context_xml
738
+
739
+ logger.warning(
740
+ f"上下文 token 估算 ({estimated}) 超出预算 ({budget} = {budget_ratio}*{window}), "
741
+ f"启动自动裁剪 (原始长度={len(context_xml)} 字符)"
742
+ )
743
+
744
+ import re
745
+
746
+ def _remove_section(xml: str, tag: str) -> str:
747
+ pattern = rf'<{tag}>[\s\S]*?</{tag}>'
748
+ replacement = f'<{tag}>\n(因 token 预算不足已裁剪)\n</{tag}>'
749
+ return re.sub(pattern, replacement, xml, count=1, flags=re.DOTALL)
750
+
751
+ # 按优先级从低到高裁剪
752
+ for tag in ['knowledge', 'recall_memory', 'automemory']:
753
+ if estimated <= budget:
754
+ break
755
+ if f'<{tag}>' in context_xml:
756
+ context_xml = _remove_section(context_xml, tag)
757
+ estimated = _est_tok(context_xml)
758
+ logger.debug(f"裁剪 <{tag}> 后 token 估算: {estimated}")
759
+
760
+ # 如果还超预算,截断 <resentdialog> 内容
761
+ if estimated > budget:
762
+ pattern = r'<resentdialog>\n([\s\S]*?)\n</resentdialog>'
763
+ match = re.search(pattern, context_xml)
764
+ if match:
765
+ dialog_text = match.group(1)
766
+ target_chars = int(budget / 1.3)
767
+ if len(dialog_text) > target_chars:
768
+ truncated = dialog_text[-target_chars:]
769
+ truncated = "(... 历史已因 token 预算不足裁剪 ...)\n" + truncated
770
+ context_xml = (
771
+ context_xml[:match.start(1)] + truncated + context_xml[match.end(1):]
772
+ )
773
+ estimated = _est_tok(context_xml)
774
+ logger.debug(f"截断对话历史后 token 估算: {estimated}")
775
+
776
+ if estimated > budget:
777
+ logger.warning(f"上下文裁剪后仍超出预算 (token={estimated}/{budget})")
778
+
779
+ return context_xml
780
+
640
781
 
641
782
  # =============================================================================
642
783
  # 工具函数
package/memory/manager.py CHANGED
@@ -278,17 +278,13 @@ class MemoryManager:
278
278
  # ==========================================================================
279
279
 
280
280
  def add_session(self, session_id, role="", content="", key="", importance=0.5, metadata=None) -> str:
281
- """添加会话记忆。内容自动注入时间前缀,确保自包含时间信息。"""
281
+ """添加会话记忆。内容不包含时间前缀,时间仅存于 created_at 和 metadata。"""
282
282
  from datetime import datetime as _dt
283
283
  _now_str = _dt.now().strftime("%Y-%m-%d %H:%M:%S")
284
- # 对话类记忆(user/assistant/system/tool)自动加时间前缀
285
- if role and content and not content.startswith("["):
286
- timestamped_content = f"[{_now_str}] {content}"
287
- else:
288
- timestamped_content = truncate_str(content, 50000)
284
+ # 直接存储原始内容,不再注入时间前缀
289
285
  entry = MemoryEntry(
290
286
  session_id=session_id, category="session", role=role,
291
- content=timestamped_content, key=key,
287
+ content=truncate_str(content, 50000), key=key,
292
288
  importance=importance, metadata={"timestamp": _now_str, **(metadata or {})},
293
289
  )
294
290
  return self._insert(entry)
@@ -366,7 +362,7 @@ class MemoryManager:
366
362
  session_id: str,
367
363
  limit: int = 50,
368
364
  ) -> str:
369
- """获取对话历史文本(供 LLM 使用)"""
365
+ """获取对话历史文本(供 LLM 使用),临时合并时间信息"""
370
366
  entries = self.get_conversation(session_id, limit)
371
367
  lines = []
372
368
  for e in entries:
@@ -379,7 +375,12 @@ class MemoryManager:
379
375
  label = "系统"
380
376
  elif e.role == "tool":
381
377
  label = "工具"
382
- lines.append(f"[{label}] {e.content}")
378
+ # 从 created_at 提取时间,临时合并到内容中给 LLM
379
+ time_str = e.created_at[:19] if e.created_at and len(e.created_at) >= 19 else ""
380
+ if time_str:
381
+ lines.append(f"[{label}] [{time_str}] {e.content}")
382
+ else:
383
+ lines.append(f"[{label}] {e.content}")
383
384
  return "\n".join(lines)
384
385
 
385
386
  def clear_conversation(self, session_id) -> int:
@@ -469,11 +470,10 @@ class MemoryManager:
469
470
  """添加全局记忆(跨会话可检索)"""
470
471
  from datetime import datetime
471
472
  now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
472
- timestamped_content = f"[{now_str}] {truncate_str(content, 50000)}"
473
473
  ts_summary = summary or truncate_str(content, 200)
474
474
  entry = MemoryEntry(
475
475
  session_id=session_id, category="global", key=key,
476
- content=timestamped_content, summary=f"[{now_str}] {ts_summary}",
476
+ content=truncate_str(content, 50000), summary=f"[{now_str}] {ts_summary}",
477
477
  importance=importance, metadata={"timestamp": now_str, **(metadata or {})},
478
478
  )
479
479
  return self._insert(entry)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "myagent-ai",
3
- "version": "1.13.0",
3
+ "version": "1.13.2",
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
@@ -155,6 +155,9 @@ class ApiServer:
155
155
  self._msg_queues: Dict[str, List[Dict]] = {}
156
156
  # 任务列表内存存储(exec 模式,替代 task.md)
157
157
  self._task_list_store: dict[str, list] = {} # session_id -> [{text, status}] (per-session, not per-agent)
158
+ # 模型链并发锁:防止并发请求互相覆盖 self.core.llm 配置
159
+ import asyncio
160
+ self._model_chain_lock = asyncio.Lock()
158
161
  self._setup_routes()
159
162
  self._runner: Optional[web.AppRunner] = None
160
163
 
@@ -3054,10 +3057,22 @@ class ApiServer:
3054
3057
  async def _try_model_chain(self, model_chain: list[dict], message: str, session_id: str,
3055
3058
  agent_path: str = None, agent_system_prompt: str = None,
3056
3059
  chat_mode: str = "") -> str:
3057
- """依次尝试模型链中的模型,直到成功或全部失败"""
3060
+ """依次尝试模型链中的模型,直到成功或全部失败
3061
+
3062
+ 使用 asyncio.Lock 保护共享的 self.core.llm,防止并发请求互相干扰。
3063
+ """
3058
3064
  if not model_chain:
3059
3065
  return await self.core.process_message(message, session_id)
3060
3066
 
3067
+ async with self._model_chain_lock:
3068
+ return await self._try_model_chain_inner(model_chain, message, session_id,
3069
+ agent_path=agent_path, agent_system_prompt=agent_system_prompt,
3070
+ chat_mode=chat_mode)
3071
+
3072
+ async def _try_model_chain_inner(self, model_chain: list[dict], message: str, session_id: str,
3073
+ agent_path: str = None, agent_system_prompt: str = None,
3074
+ chat_mode: str = "") -> str:
3075
+ """_try_model_chain 的实际执行体(已在 _model_chain_lock 保护下)"""
3061
3076
  llm = self.core.llm
3062
3077
  last_error = ""
3063
3078
  used_model_name = ""
@@ -3152,12 +3167,26 @@ class ApiServer:
3152
3167
  async def _try_model_chain_stream(self, model_chain, message, session_id,
3153
3168
  agent_path=None, agent_system_prompt=None,
3154
3169
  chat_mode="", stream_response=None):
3155
- """流式版本的模型链调用,逐token输出到SSE"""
3170
+ """流式版本的模型链调用,逐token输出到SSE
3171
+
3172
+ 使用 asyncio.Lock 保护共享的 self.core.llm,防止并发请求互相干扰。
3173
+ """
3156
3174
  if not model_chain:
3157
3175
  result = await self.core.process_message(message, session_id)
3158
3176
  await stream_response.write(("data: " + json.dumps({"type": "text", "content": result}) + "\n\n").encode())
3159
3177
  return result
3160
3178
 
3179
+ async with self._model_chain_lock:
3180
+ return await self._try_model_chain_stream_inner(
3181
+ model_chain, message, session_id,
3182
+ agent_path=agent_path, agent_system_prompt=agent_system_prompt,
3183
+ chat_mode=chat_mode, stream_response=stream_response,
3184
+ )
3185
+
3186
+ async def _try_model_chain_stream_inner(self, model_chain, message, session_id,
3187
+ agent_path=None, agent_system_prompt=None,
3188
+ chat_mode="", stream_response=None):
3189
+ """_try_model_chain_stream 的实际执行体(已在 _model_chain_lock 保护下)"""
3161
3190
  llm = self.core.llm
3162
3191
  full_text = ""
3163
3192
 
@@ -455,7 +455,7 @@ input,textarea,select{font:inherit}
455
455
 
456
456
  /* ── Message Content Smooth Render ── */
457
457
  .message-content{
458
- min-width:0;
458
+ flex:1;min-width:0;
459
459
  }
460
460
  .stream-text-node{
461
461
  display:inline;
@@ -2005,7 +2005,6 @@ body.popout-mode .main{margin-left:0 !important;border-left:none !important}
2005
2005
  body.popout-mode .agent-panel{display:none !important}
2006
2006
  body.popout-mode .main-header{padding-left:12px}
2007
2007
  body.popout-mode #popoutBtn{display:none !important}
2008
- body.popout-mode #debugToggleBtn{display:none !important}
2009
2008
 
2010
2009
  /* ══════════════════════════════════════════════════════
2011
2010
  ── Mobile Responsive (≤768px) ──
@@ -2547,32 +2547,27 @@ function formatTime(timeStr) {
2547
2547
  }
2548
2548
  }
2549
2549
 
2550
+ // ── User scroll lock: when user manually scrolls up, stop auto-scrolling ──
2551
+ var _userScrollLocked = false;
2552
+
2550
2553
  function scrollToBottom(force) {
2551
2554
  const c = document.getElementById('messagesContainer');
2552
2555
  if (!c) return;
2553
- // During streaming: pin the active assistant message to the top of the chat window
2554
- // so the user can see the full response content below
2555
- const isStreaming = state.isGenerating;
2556
- if (isStreaming && !force) {
2557
- const activeRow = c.querySelector('.message-row.assistant.streaming, .message-row.assistant:last-of-type');
2558
- if (activeRow) {
2559
- requestAnimationFrame(() => {
2560
- const rowTop = activeRow.offsetTop;
2561
- // Scroll so the assistant row sits at the very top of the visible area
2562
- c.scrollTop = rowTop;
2563
- updateScrollToBottomBtn(c.scrollHeight - c.scrollTop - c.clientHeight);
2564
- });
2565
- return;
2566
- }
2567
- }
2568
2556
  requestAnimationFrame(() => {
2569
- // Smart scroll: only auto-scroll if user is near bottom (within 120px)
2570
- // or if force is true
2571
2557
  const distFromBottom = c.scrollHeight - c.scrollTop - c.clientHeight;
2558
+ // If user has manually scrolled away, don't auto-scroll (unless forced)
2559
+ if (!force && _userScrollLocked) {
2560
+ updateScrollToBottomBtn(distFromBottom);
2561
+ return;
2562
+ }
2563
+ // Smart scroll: only auto-scroll if near bottom (within 120px) or forced
2572
2564
  if (force || distFromBottom < 120) {
2573
2565
  c.scrollTop = c.scrollHeight;
2566
+ _userScrollLocked = false;
2567
+ } else {
2568
+ // User is far from bottom — lock auto-scroll
2569
+ _userScrollLocked = true;
2574
2570
  }
2575
- // Update scroll-to-bottom button visibility
2576
2571
  updateScrollToBottomBtn(distFromBottom);
2577
2572
  });
2578
2573
  }
@@ -2603,7 +2598,7 @@ function initScrollToBottomBtn() {
2603
2598
  btn.id = 'scrollToBottomBtn';
2604
2599
  btn.className = 'scroll-to-bottom-btn';
2605
2600
  btn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14M5 12l7 7 7-7"/></svg>';
2606
- btn.onclick = function() { scrollToBottom(true); };
2601
+ btn.onclick = function() { _userScrollLocked = false; scrollToBottom(true); };
2607
2602
  // Insert into the main area (parent of messagesContainer)
2608
2603
  const mainArea = container.parentElement;
2609
2604
  if (mainArea) {
@@ -2611,9 +2606,14 @@ function initScrollToBottomBtn() {
2611
2606
  mainArea.appendChild(btn);
2612
2607
  }
2613
2608
 
2614
- // Listen for manual scroll to show/hide button
2609
+ // Listen for manual scroll to show/hide button and detect user scroll-away
2615
2610
  container.addEventListener('scroll', function() {
2616
- updateScrollToBottomBtn();
2611
+ const dist = container.scrollHeight - container.scrollTop - container.clientHeight;
2612
+ // Lock auto-scroll when user scrolls more than 120px away from bottom
2613
+ if (dist > 120) {
2614
+ _userScrollLocked = true;
2615
+ }
2616
+ updateScrollToBottomBtn(dist);
2617
2617
  }, { passive: true });
2618
2618
  }
2619
2619
 
@@ -393,6 +393,8 @@ function updateStreamingMessage(msgIdx) {
393
393
  const newText = msg.reasoning.substring(prevLen);
394
394
  thoughtContent.insertAdjacentHTML('beforeend', renderMarkdown(newText));
395
395
  reasoningDetails._lastReasoningLen = msg.reasoning.length;
396
+ // 自动滚动推理框内部内容到底部(不滚动整个页面)
397
+ thoughtContent.scrollTop = thoughtContent.scrollHeight;
396
398
  }
397
399
  } else if (thoughtContent && !msg.streaming) {
398
400
  // Final render once streaming stops
@@ -443,6 +445,8 @@ function updateStreamingMessage(msgIdx) {
443
445
  const newText = msg.thought.substring(prevLen);
444
446
  thoughtContent.insertAdjacentHTML('beforeend', renderMarkdown(newText));
445
447
  thoughtBlock._lastThoughtLen = msg.thought.length;
448
+ // 自动滚动思考框内部内容到底部
449
+ thoughtContent.scrollTop = thoughtContent.scrollHeight;
446
450
  }
447
451
  } else if (thoughtContent && !msg.streaming) {
448
452
  thoughtContent.innerHTML = renderMarkdown(msg.thought);
@@ -499,6 +503,8 @@ function updateStreamingMessage(msgIdx) {
499
503
  const newText = msg._v2Reasoning.substring(prevLen);
500
504
  thoughtContent.insertAdjacentHTML('beforeend', renderMarkdown(newText));
501
505
  v2ReasoningBlock._lastV2Len = msg._v2Reasoning.length;
506
+ // 自动滚动 V2 推理框内部内容到底部
507
+ thoughtContent.scrollTop = thoughtContent.scrollHeight;
502
508
  }
503
509
  } else if (thoughtContent && !msg.streaming) {
504
510
  thoughtContent.innerHTML = renderMarkdown(msg._v2Reasoning);
@@ -855,6 +861,41 @@ function toggleExecEventsPanel(header) {
855
861
  // ── Inline Exec Event (Timeline Card) ──
856
862
  // ══════════════════════════════════════════════════════
857
863
 
864
+ // Update an existing V2 tool card in the DOM (replace spinner with result icon)
865
+ function _updateToolCardInDOM(msgIdx, partIdx) {
866
+ var container = document.getElementById('messagesInner');
867
+ if (!container) return;
868
+ // Find the target message row
869
+ var allRows = container.querySelectorAll('.message-row');
870
+ var targetRow = null;
871
+ var rowCount = 0;
872
+ for (var ri = 0; ri < allRows.length; ri++) {
873
+ if (rowCount === msgIdx) { targetRow = allRows[ri]; break; }
874
+ rowCount++;
875
+ }
876
+ if (!targetRow) return;
877
+ // Find the timeline inside the message
878
+ var timeline = targetRow.querySelector('.msg-timeline');
879
+ if (!timeline) return;
880
+ // Find all V2 tool event cards in the timeline
881
+ var toolCards = timeline.querySelectorAll('.v2-tool-event');
882
+ // partIdx is the index in msgParts — count only v2_tool parts to find the right card
883
+ var msg = state.messages[msgIdx];
884
+ if (!msg || !msg.parts) return;
885
+ var toolPartCount = 0;
886
+ var targetCard = null;
887
+ for (var ti = 0; ti < msg.parts.length && ti <= partIdx; ti++) {
888
+ if (msg.parts[ti].type === 'v2_tool') {
889
+ if (ti === partIdx) { targetCard = toolCards[toolPartCount]; break; }
890
+ toolPartCount++;
891
+ }
892
+ }
893
+ if (!targetCard) return;
894
+ // Re-render the card with updated data
895
+ var updatedHtml = renderInlineExecEvent(msg.parts[partIdx], msgIdx);
896
+ targetCard.outerHTML = updatedHtml;
897
+ }
898
+
858
899
  function renderInlineExecEvent(data, msgIdx) {
859
900
  // V2 Tool Event handling (called with full part: {type:'v2_tool', data:{...}})
860
901
  if (data.type === 'v2_tool') {
@@ -1168,6 +1209,16 @@ async function sendMessage() {
1168
1209
  sessionId = `${state.activeAgent}_web_${ts}`;
1169
1210
  state.activeSessionId = sessionId;
1170
1211
  document.getElementById('headerTitle').textContent = formatSessionName(sessionId);
1212
+ // ── 立即在左侧边栏添加新会话条目(不等后端返回) ──
1213
+ state.sessions.unshift({
1214
+ id: sessionId,
1215
+ name: formatSessionName(sessionId),
1216
+ messages: 0,
1217
+ last: new Date().toISOString(),
1218
+ preview: '',
1219
+ });
1220
+ state.agentSessions[state.activeAgent] = [...state.sessions];
1221
+ renderSessions();
1171
1222
  // ── 更新 URL 参数,携带会话 ID(刷新页面可恢复) ──
1172
1223
  try {
1173
1224
  const url = new URL(window.location.href);
@@ -1349,6 +1400,7 @@ async function sendMessage() {
1349
1400
  fullThought = '';
1350
1401
  state.messages.push({ role: 'assistant', content: '', thought: '', parts: [], time: new Date().toISOString(), streaming: true });
1351
1402
  renderMessages();
1403
+ _userScrollLocked = false;
1352
1404
  scrollToBottom(true); // Force scroll for new message
1353
1405
  } else if (evt.type === 'clear_text') {
1354
1406
  // Clear intermediate text from previous agent loop iterations
@@ -1437,36 +1489,66 @@ async function sendMessage() {
1437
1489
  state.messages[msgIdx].exec_events = [...allExecEvents];
1438
1490
  throttledStreamUpdate(msgIdx);
1439
1491
  } else if (evt.type === 'v2_tool_result') {
1440
- // Tool execution completed
1441
- // Stop the tool timer
1442
- if (evt.tool && evt.tool.toolname) {
1443
- // Find matching timer by checking all active timers
1444
- for (var tId in _toolTimers) {
1445
- stopToolTimer(tId);
1446
- }
1447
- }
1448
- // evt.tool contains: {beforecalltext, toolname, ...}
1449
- // evt.result contains: {success, output, error, ...}
1492
+ // Tool execution completed — find and UPDATE the matching start card
1450
1493
  var _r = evt.result || {};
1451
1494
  var _t = evt.tool || {};
1452
- var resultEvent = {
1453
- type: 'v2_tool',
1454
- data: {
1455
- id: 'v2tool_' + Date.now() + '_' + allExecEvents.length,
1456
- type: 'tool_result',
1457
- title: (_t.toolname || '工具') + ' 执行完成',
1458
- tool_name: _t.toolname,
1459
- success: !!_r.success,
1460
- summary: (_r.output || _r.error || '').substring(0, 500),
1461
- result: _r,
1462
- callback: _t.callback
1495
+ var _toolName = _t.toolname || '';
1496
+ // Find the matching tool_start part in msgParts by tool_name
1497
+ var _matchedIdx = -1;
1498
+ for (var _pi = msgParts.length - 1; _pi >= 0; _pi--) {
1499
+ if (msgParts[_pi].type === 'v2_tool' && msgParts[_pi].data.tool_name === _toolName && msgParts[_pi].data.status === 'running') {
1500
+ _matchedIdx = _pi;
1501
+ break;
1463
1502
  }
1464
- };
1465
- msgParts.push(resultEvent);
1466
- allExecEvents.push(resultEvent.data);
1467
- state.messages[msgIdx].parts = [...msgParts];
1468
- state.messages[msgIdx].exec_events = [...allExecEvents];
1469
- throttledStreamUpdate(msgIdx);
1503
+ }
1504
+ if (_matchedIdx >= 0) {
1505
+ // Update the existing start card in-place
1506
+ var _startData = msgParts[_matchedIdx].data;
1507
+ // Stop timer using the start card's original ID
1508
+ stopToolTimer(_startData.id);
1509
+ // Update the part data to reflect completion
1510
+ _startData.status = 'done';
1511
+ _startData.type = 'tool_result';
1512
+ _startData.success = !!_r.success;
1513
+ _startData.summary = (_r.output || _r.error || '').substring(0, 500);
1514
+ _startData.result = _r;
1515
+ _startData.title = (_t.toolname || '工具') + ' 执行完成';
1516
+ // Update in allExecEvents too
1517
+ for (var _ei = allExecEvents.length - 1; _ei >= 0; _ei--) {
1518
+ if (allExecEvents[_ei].id === _startData.id) {
1519
+ Object.assign(allExecEvents[_ei], _startData);
1520
+ break;
1521
+ }
1522
+ }
1523
+ // Force re-render this specific card in the DOM
1524
+ _updateToolCardInDOM(msgIdx, _matchedIdx);
1525
+ state.messages[msgIdx].parts = [...msgParts];
1526
+ state.messages[msgIdx].exec_events = [...allExecEvents];
1527
+ } else {
1528
+ // Fallback: no matching start found, push as new part
1529
+ var _fallbackTimerIds = Object.keys(_toolTimers);
1530
+ for (var _fi = 0; _fi < _fallbackTimerIds.length; _fi++) {
1531
+ stopToolTimer(_fallbackTimerIds[_fi]);
1532
+ }
1533
+ var resultEvent = {
1534
+ type: 'v2_tool',
1535
+ data: {
1536
+ id: 'v2tool_' + Date.now() + '_' + allExecEvents.length,
1537
+ type: 'tool_result',
1538
+ title: (_t.toolname || '工具') + ' 执行完成',
1539
+ tool_name: _t.toolname,
1540
+ success: !!_r.success,
1541
+ summary: (_r.output || _r.error || '').substring(0, 500),
1542
+ result: _r,
1543
+ callback: _t.callback
1544
+ }
1545
+ };
1546
+ msgParts.push(resultEvent);
1547
+ allExecEvents.push(resultEvent.data);
1548
+ state.messages[msgIdx].parts = [...msgParts];
1549
+ state.messages[msgIdx].exec_events = [...allExecEvents];
1550
+ throttledStreamUpdate(msgIdx);
1551
+ }
1470
1552
  } else if (evt.type === 'v2_task_plan') {
1471
1553
  // Updated task plan from V2 output
1472
1554
  if (evt.plan) {