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.
- package/agents/main_agent.py +5 -1
- package/core/context_builder.py +148 -7
- package/memory/manager.py +11 -11
- package/package.json +1 -1
- package/web/api_server.py +31 -2
- package/web/ui/chat/chat.css +1 -2
- package/web/ui/chat/chat_main.js +21 -21
- package/web/ui/chat/flow_engine.js +109 -27
package/agents/main_agent.py
CHANGED
|
@@ -454,7 +454,11 @@ class MainAgent(BaseAgent):
|
|
|
454
454
|
)
|
|
455
455
|
if db_history:
|
|
456
456
|
conversation_history = [
|
|
457
|
-
Message(
|
|
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)} 条历史对话")
|
package/core/context_builder.py
CHANGED
|
@@ -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
|
-
|
|
352
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
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=
|
|
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
|
-
|
|
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=
|
|
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
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
|
|
package/web/ui/chat/chat.css
CHANGED
|
@@ -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) ──
|
package/web/ui/chat/chat_main.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
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
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
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) {
|