v0.15.0: Agent ReAct Loop 核心引擎深度审计与生产级增强 (R1-R50)

核心引擎健壮性(R1-R10)、上下文管理优化(R11-R20)、工具安全与验证(R21-R30)、UI渲染性能(R31-R40)、基础设施与监控(R41-R50)、版本号升级
This commit is contained in:
thzxx
2026-07-11 20:56:48 +08:00
parent e04320d454
commit cb2ce48eb7
15 changed files with 2012 additions and 170 deletions
+169 -15
View File
@@ -112,20 +112,22 @@ export function resetAutoScroll(): void {
/** P1-1: 已渲染消息索引集合,用于稳定增量 diff(替代 DOM querySelectorAll 计数) */
const _renderedMsgIndices = new Set<number>();
/** R31: 最大渲染消息数(超过此数时仅渲染最近的消息,避免 DOM 过载) */
const MAX_RENDERED_MESSAGES = 200;
/** 标记当前渲染批次中系统提示词卡片是否已渲染(每会话仅首条 assistant 消息展示) */
let _sysPromptRendered = false;
/** R31: 增量渲染 — 仅追加新消息,避免全量重建 */
export function renderMessages(): void {
const currentSession = state.get<ChatSession | null>(KEYS.CURRENT_SESSION);
const msgs = currentSession ? currentSession.messages : [];
// 全量清空重建,避免增量 diff 的边界 bug
messagesContainerEl.innerHTML = '';
currentPlaceholder = null;
_renderedMsgIndices.clear();
_sysPromptRendered = false; // 重置:每轮全量渲染时重新判定首条 assistant
if (msgs.length === 0) {
messagesContainerEl.innerHTML = '';
currentPlaceholder = null;
_renderedMsgIndices.clear();
_sysPromptRendered = false;
emptyStateEl.style.display = '';
messagesContainerEl.style.display = 'none';
return;
@@ -134,16 +136,52 @@ export function renderMessages(): void {
emptyStateEl.style.display = 'none';
messagesContainerEl.style.display = '';
for (let i = 0; i < msgs.length; i++) {
const msg = msgs[i];
// 跳过 system 消息(Plan Mode 进度等内部状态消息不展示给用户)
if (msg.role === 'system') continue;
// 只跳过既无内容也无 think 也无工具的空消息
if (msg.role === 'assistant' && !msg.content && !msg.think && (!msg.toolCalls || msg.toolCalls.length === 0)) continue;
appendMessageDOM(msgs[i], i);
_renderedMsgIndices.add(i);
// R31: 增量渲染 — 检查是否需要全量重建
const needsFullRebuild = _renderedMsgIndices.size === 0
|| _renderedMsgIndices.size > msgs.length
|| Array.from(_renderedMsgIndices).some(idx => idx >= msgs.length);
if (needsFullRebuild) {
// 全量重建(首次加载或消息被截断/压缩后)
messagesContainerEl.innerHTML = '';
currentPlaceholder = null;
_renderedMsgIndices.clear();
_sysPromptRendered = false;
// R31: 仅渲染最近 MAX_RENDERED_MESSAGES 条消息,避免 DOM 过载
const startIdx = Math.max(0, msgs.length - MAX_RENDERED_MESSAGES);
if (startIdx > 0) {
const noticeDiv = document.createElement('div');
noticeDiv.className = 'msg-truncated-notice';
noticeDiv.style.cssText = 'text-align:center;padding:8px;font-size:12px;color:var(--text-muted);';
noticeDiv.textContent = `⋯ 已折叠前 ${startIdx} 条消息,仅显示最近 ${MAX_RENDERED_MESSAGES} 条 ⋯`;
messagesContainerEl.appendChild(noticeDiv);
}
for (let i = startIdx; i < msgs.length; i++) {
const msg = msgs[i];
if (msg.role === 'system') continue;
if (msg.role === 'assistant' && !msg.content && !msg.think && (!msg.toolCalls || msg.toolCalls.length === 0)) continue;
appendMessageDOM(msgs[i], i);
_renderedMsgIndices.add(i);
}
} else {
// R31: 增量追加 — 只渲染新增的消息
for (let i = 0; i < msgs.length; i++) {
if (_renderedMsgIndices.has(i)) continue;
const msg = msgs[i];
if (msg.role === 'system') continue;
if (msg.role === 'assistant' && !msg.content && !msg.think && (!msg.toolCalls || msg.toolCalls.length === 0)) continue;
appendMessageDOM(msgs[i], i);
_renderedMsgIndices.add(i);
}
}
// R38: 为代码块添加复制按钮
addCodeBlockCopyButtons();
// R40: 为消息添加操作按钮
addMessageActions();
scrollToBottom();
}
@@ -353,6 +391,8 @@ export function updateLastAssistantMessage(
// P0-1: 流式期间纯文本追加(避免每 chunk 全量 Markdown + DOMPurify),最终渲染时才做 Markdown
if (isFinal) {
contentDiv.innerHTML = safeMarkdown(safeContent);
// R38: 最终渲染后为代码块添加复制按钮
addCodeBlockCopyButtons(contentDiv);
} else {
contentDiv.textContent = safeContent;
}
@@ -459,21 +499,53 @@ export function updateToolCallCardInPlaceholder(tc: ToolCallRecord): void {
export function appendAssistantPlaceholder(): void {
const div = document.createElement('div');
div.className = 'message assistant loading';
// R34: 增强加载状态 — 添加旋转提示文本
div.innerHTML = `
<div class="msg-avatar">🤖</div>
<div class="msg-body">
<div class="msg-role">AI</div>
<div class="msg-content">
<div class="loading-dots"><span></span><span></span><span></span></div>
<span class="loading-text">正在思考...</span>
<span class="loading-text" data-hint-index="0">正在思考...</span>
</div>
</div>
`;
messagesContainerEl.appendChild(div);
currentPlaceholder = div;
// R34: 启动旋转提示
startLoadingHintRotation(div);
scrollToBottom();
}
// R34: 加载提示旋转动画
const LOADING_HINTS = [
'正在思考...',
'正在分析问题...',
'正在组织回答...',
'正在查找信息...',
'正在生成回复...',
];
let _loadingHintInterval: ReturnType<typeof setInterval> | null = null;
function startLoadingHintRotation(el: HTMLElement): void {
if (_loadingHintInterval) clearInterval(_loadingHintInterval);
let idx = 0;
_loadingHintInterval = setInterval(() => {
if (!el.isConnected) {
if (_loadingHintInterval) clearInterval(_loadingHintInterval);
_loadingHintInterval = null;
return;
}
idx = (idx + 1) % LOADING_HINTS.length;
const hintEl = el.querySelector('.loading-text');
if (hintEl) {
hintEl.textContent = LOADING_HINTS[idx];
}
}, 3000);
}
export function appendSystemMessage(text: string, tempClass?: string): void {
const div = document.createElement('div');
div.style.cssText = 'text-align:center;padding:8px;font-size:12px;color:var(--text-muted);';
@@ -502,6 +574,11 @@ export function resetCurrentPlaceholder(): void {
if (currentPlaceholder && !currentPlaceholder.isConnected) {
currentPlaceholder = null;
}
// R34: 清理加载提示旋转
if (_loadingHintInterval) {
clearInterval(_loadingHintInterval);
_loadingHintInterval = null;
}
}
/** 从 DOM 移除当前 placeholder 并清空引用 */
@@ -517,6 +594,83 @@ export function enableAutoScroll(): void {
if (scrollBtnEl) scrollBtnEl.style.display = 'none';
}
// ── R38: 代码块复制按钮 ──
/** R38: 为代码块添加复制按钮 */
function addCodeBlockCopyButtons(scope?: ParentNode): void {
const root = scope || messagesContainerEl;
const codeBlocks = root.querySelectorAll('pre:not([data-copy-added])');
for (const pre of codeBlocks) {
pre.setAttribute('data-copy-added', 'true');
const btn = document.createElement('button');
btn.className = 'code-copy-btn';
btn.textContent = '📋 复制';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:4px;cursor:pointer;opacity:0;transition:opacity 0.2s;';
// 确保 pre 是相对定位
if (getComputedStyle(pre).position === 'static') {
(pre as HTMLElement).style.position = 'relative';
}
pre.addEventListener('mouseenter', () => { btn.style.opacity = '1'; });
pre.addEventListener('mouseleave', () => { btn.style.opacity = '0'; });
btn.addEventListener('click', (e) => {
e.preventDefault();
const code = pre.querySelector('code');
const text = code ? code.textContent : pre.textContent;
if (text) {
navigator.clipboard.writeText(text).then(() => {
btn.textContent = '✅ 已复制';
setTimeout(() => { btn.textContent = '📋 复制'; }, 2000);
}).catch(() => {
btn.textContent = '❌ 失败';
setTimeout(() => { btn.textContent = '📋 复制'; }, 2000);
});
}
});
pre.appendChild(btn);
}
}
// ── R40: 消息操作按钮 ──
/** R40: 为消息添加操作按钮(复制、重试) */
function addMessageActions(): void {
const messages = messagesContainerEl.querySelectorAll('.message:not([data-actions-added])');
for (const msg of messages) {
msg.setAttribute('data-actions-added', 'true');
const actionBar = document.createElement('div');
actionBar.className = 'msg-actions';
actionBar.style.cssText = 'position:absolute;top:4px;right:4px;display:flex;gap:4px;opacity:0;transition:opacity 0.2s;';
// 确保 message 是相对定位
if (getComputedStyle(msg).position === 'static') {
(msg as HTMLElement).style.position = 'relative';
}
// 复制按钮
const copyBtn = document.createElement('button');
copyBtn.className = 'msg-action-btn msg-copy-btn';
copyBtn.textContent = '📋';
copyBtn.title = '复制消息';
copyBtn.style.cssText = 'padding:2px 6px;font-size:12px;background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:4px;cursor:pointer;';
copyBtn.addEventListener('click', (e) => {
e.preventDefault();
const contentEl = msg.querySelector('.msg-content');
const text = contentEl ? contentEl.textContent : '';
if (text) {
navigator.clipboard.writeText(text).then(() => {
copyBtn.textContent = '✅';
setTimeout(() => { copyBtn.textContent = '📋'; }, 2000);
}).catch(() => {});
}
});
actionBar.appendChild(copyBtn);
msg.addEventListener('mouseenter', () => { actionBar.style.opacity = '1'; });
msg.addEventListener('mouseleave', () => { actionBar.style.opacity = '0'; });
msg.appendChild(actionBar);
}
}
// ── 导出功能 ──
async function nativeSaveFile(defaultName: string, content: string): Promise<void> {