优化agent配置,修复已知问题

This commit is contained in:
minorQAQ committed 2026-08-12 17:48:17 +08:00
1 parent 744cae8f54
commit 9acb38c9e6
55 files changed
+3009 -623

No files matched your search

+384 -19
View File
@@ -1,6 +1,6 @@
// chat-render.js -- 消息渲染 / 流式输出 / 音频气泡(新版:无气泡 + 媒体行 + agent-run-block)
import { $, escapeHtml, getTextFromContent, getMediaParts, isAudioOnlyAssistantMessage, clamp, formatAudioDuration, getAudioBubbleWidth, codeBlockNode, inferUploadKind, dedupePendingFiles } from './utils.js';
import { $, escapeHtml, getTextFromContent, getMediaParts, isAudioOnlyAssistantMessage, clamp, formatAudioDuration, getAudioBubbleWidth, codeBlockNode, inferUploadKind, dedupePendingFiles, showToast } from './utils.js';
import { state } from './state.js';
import {
showImagePreview,
@@ -50,11 +50,13 @@ function renderAttachmentChips() {
const refImages = [];
const refFiles = [];
const refFolderItems = [];
const refQuotes = [];
state.pendingFiles.forEach((f, i) => {
if (f.__isRef) {
if (f.type === 'ref/image') refImages.push({ file: f, idx: i });
else if (f.type === 'ref/folder') refFolderItems.push({ file: f, idx: i });
else if (f.type === 'ref/quote') refQuotes.push({ file: f, idx: i });
else refFiles.push({ file: f, idx: i });
} else {
const kind = inferUploadKind(f);
@@ -189,6 +191,23 @@ function renderAttachmentChips() {
hasContent = true;
});
// 聊天区文字引用 chips(复用 ref-chip UI,注明为用户引用)
refQuotes.forEach(({ file, idx }) => {
const chip = document.createElement("span");
chip.className = "ref-chip ref-chip--quote";
chip.title = "用户引用:" + (file.refText || file.refName || "");
chip.innerHTML = `<img src="/image/回复.svg" alt="" class="ref-chip-icon" /> <span class="ref-chip-label">${escapeHtml(file.refName || file.name)}</span> <button type="button" class="ref-chip-close" aria-label="移除">\u00D7</button>`;
chip.querySelector("button").addEventListener("click", () => {
state.pendingFiles = state.pendingFiles.filter((_, j) => j !== idx);
if (file.refPath) {
state.pendingRefs = state.pendingRefs.filter((r) => r.path !== file.refPath);
}
renderAttachmentChips();
});
chipsArea.appendChild(chip);
hasContent = true;
});
if (hasContent || imagesArea.children.length > 0 || chipsArea.children.length > 0) {
wrap.classList.add("has-content");
} else {
@@ -955,29 +974,15 @@ function renderMessage(msg, allowAutoplay = false) {
};
if (msg.role === "user") {
// 用户消息:回退按钮 + 内容(无气泡)
const rollbackBtn = document.createElement("button");
rollbackBtn.className = "chat-rollback-btn";
rollbackBtn.textContent = "\u21A9";
rollbackBtn.title = "回退到此消息";
rollbackBtn.addEventListener("click", (e) => {
e.stopPropagation();
if (!chatMessages) return;
const children = Array.from(chatMessages.children);
const idx = children.indexOf(wrap);
if (idx < 0) return;
if (rollbackChatFn) {
rollbackChatFn(idx);
}
});
wrap.appendChild(rollbackBtn);
// 用户消息:内容 + 下方操作按钮(复制 / 撤回)
const contentEl = renderUserContent(msg.content, baseOptions);
wrap.appendChild(contentEl);
wrap.appendChild(renderMessageActions("user", msg, wrap));
} else {
// 助手消息
// 助手消息:内容 + 下方操作按钮(复制 / 重试)
const contentEl = renderAssistantContent(msg, allowAutoplay);
wrap.appendChild(contentEl);
wrap.appendChild(renderMessageActions("assistant", msg, wrap));
if (msg.meta && msg.meta.pending_action) {
// pending_action 不再内嵌在气泡中,由 pending-overlay 浮窗展示
}
@@ -986,6 +991,362 @@ function renderMessage(msg, allowAutoplay = false) {
return wrap;
}
/* ===== 消息下方操作按钮行(无边框图标按钮) ===== */
function renderMessageActions(role, msg, wrap) {
const actions = document.createElement("div");
actions.className = "msg-actions msg-actions--" + role;
// 复制到剪贴板
const copyBtn = document.createElement("button");
copyBtn.type = "button";
copyBtn.className = "chat-action-btn";
copyBtn.title = "复制";
copyBtn.setAttribute("aria-label", "复制消息内容");
const copyImg = document.createElement("img");
copyImg.src = "/image/复制.svg";
copyImg.alt = "";
copyImg.draggable = false;
copyBtn.appendChild(copyImg);
copyBtn.addEventListener("click", (e) => {
e.stopPropagation();
const text = getTextFromContent(msg.content);
if (!text) {
showToast("没有可复制的内容");
return;
}
navigator.clipboard.writeText(text).then(() => {
showToast("已复制到剪贴板");
}).catch(() => {
showToast("复制失败");
});
});
actions.appendChild(copyBtn);
if (role === "user") {
// 撤回:回退到此消息
const rollbackBtn = document.createElement("button");
rollbackBtn.type = "button";
rollbackBtn.className = "chat-action-btn";
rollbackBtn.title = "撤回";
rollbackBtn.setAttribute("aria-label", "撤回此消息");
const rbImg = document.createElement("img");
rbImg.src = "/image/撤回.svg";
rbImg.alt = "";
rbImg.draggable = false;
rollbackBtn.appendChild(rbImg);
rollbackBtn.addEventListener("click", (e) => {
e.stopPropagation();
if (!chatMessages) return;
const children = Array.from(chatMessages.children);
const idx = children.indexOf(wrap);
if (idx < 0) return;
if (rollbackChatFn) rollbackChatFn(idx);
});
actions.appendChild(rollbackBtn);
} else {
// 点赞 / 点踩(本地持久化,刷新/切换会话后保留)
const msgIdx = (() => {
if (!chatMessages) return -1;
return Array.from(chatMessages.children).indexOf(wrap);
})();
const fbKey = _feedbackKey(msg, msgIdx);
const fbMap0 = _loadFeedbackMap();
const curVal = fbMap0[fbKey] || "";
const likeBtn = document.createElement("button");
likeBtn.type = "button";
likeBtn.className = "chat-action-btn chat-feedback-btn";
likeBtn.title = "点赞";
likeBtn.setAttribute("aria-label", "点赞");
const likeImg = document.createElement("img");
likeImg.src = "/image/点赞.svg";
likeImg.alt = "";
likeImg.draggable = false;
likeBtn.appendChild(likeImg);
if (curVal === "like") likeBtn.classList.add("is-active");
const dislikeBtn = document.createElement("button");
dislikeBtn.type = "button";
dislikeBtn.className = "chat-action-btn chat-feedback-btn";
dislikeBtn.title = "点踩";
dislikeBtn.setAttribute("aria-label", "点踩");
const dislikeImg = document.createElement("img");
dislikeImg.src = "/image/点踩.svg";
dislikeImg.alt = "";
dislikeImg.draggable = false;
dislikeBtn.appendChild(dislikeImg);
if (curVal === "dislike") dislikeBtn.classList.add("is-active");
const applyFeedback = (val) => {
const map = _loadFeedbackMap();
const next = map[fbKey] === val ? "" : val;
map[fbKey] = next;
_saveFeedbackMap(map);
likeBtn.classList.toggle("is-active", next === "like");
dislikeBtn.classList.toggle("is-active", next === "dislike");
showToast(next === "like" ? "已点赞" : next === "dislike" ? "已点踩" : "已取消评价");
};
likeBtn.addEventListener("click", (e) => {
e.stopPropagation();
applyFeedback("like");
});
dislikeBtn.addEventListener("click", (e) => {
e.stopPropagation();
applyFeedback("dislike");
});
actions.appendChild(likeBtn);
actions.appendChild(dislikeBtn);
// 重试:删除本轮记录(撤回)并重新执行 agent(撤回 + 发送)
const retryBtn = document.createElement("button");
retryBtn.type = "button";
retryBtn.className = "chat-action-btn";
retryBtn.title = "重试";
retryBtn.setAttribute("aria-label", "重试本轮回复");
const rtImg = document.createElement("img");
rtImg.src = "/image/重试.svg";
rtImg.alt = "";
rtImg.draggable = false;
retryBtn.appendChild(rtImg);
retryBtn.addEventListener("click", (e) => {
e.stopPropagation();
handleRetry(wrap);
});
actions.appendChild(retryBtn);
// 分支:将当前回复及之前的所有历史复制到新会话
const branchBtn = document.createElement("button");
branchBtn.type = "button";
branchBtn.className = "chat-action-btn";
branchBtn.title = "分支";
branchBtn.setAttribute("aria-label", "复制历史到新会话");
const brImg = document.createElement("img");
brImg.src = "/image/分支.svg";
brImg.alt = "";
brImg.draggable = false;
branchBtn.appendChild(brImg);
branchBtn.addEventListener("click", (e) => {
e.stopPropagation();
handleBranch(wrap, msg);
});
actions.appendChild(branchBtn);
// 分支右侧:SSE 开始时间(即该轮 AI 回复开始的时间,时:分)
const turnMeta = (msg && msg.meta && msg.meta.turn_meta) || {};
if (turnMeta.started_at) {
const timeEl = document.createElement("span");
timeEl.className = "msg-start-time";
timeEl.textContent = _formatStartTime(turnMeta.started_at);
timeEl.title = "本轮回复开始时间";
actions.appendChild(timeEl);
}
}
return actions;
}
/** 将 Unix 秒时间戳格式化为 时:分(24 小时制,补零) */
function _formatStartTime(startedAt) {
const ts = Number(startedAt);
if (!Number.isFinite(ts) || ts <= 0) return "";
const d = new Date(ts * 1000);
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
return `${hh}:${mm}`;
}
/** 分支:将当前回复及之前的所有历史复制到新会话并跳转 */
async function handleBranch(wrap, msg) {
if (state.sending) {
showToast("Agent 运行中,无法分支。请先暂停任务。");
return;
}
if (!state.sessionId) {
showToast("无当前会话");
return;
}
const turnId = msg && msg.meta && msg.meta.turn_id;
if (!turnId) {
showToast("无法获取该回复的回合信息");
return;
}
try {
const { api } = await import("./api.js");
const fd = new FormData();
fd.append("session_id", state.sessionId);
fd.append("branch_turn_id", turnId);
const data = await api("/api/sessions/branch", { method: "POST", body: fd });
const { applySessionData } = await import("./sessions.js");
await applySessionData(data);
showToast("已创建分支会话,历史已复制到新会话");
} catch (err) {
showToast("分支失败: " + (err.message || "未知错误"));
}
}
/* ===== 点赞 / 点踩 本地持久化 ===== */
const FEEDBACK_STORAGE_KEY = "minor_agent_msg_feedback";
function _loadFeedbackMap() {
try {
return JSON.parse(localStorage.getItem(FEEDBACK_STORAGE_KEY) || "{}");
} catch {
return {};
}
}
function _saveFeedbackMap(map) {
try {
localStorage.setItem(FEEDBACK_STORAGE_KEY, JSON.stringify(map));
} catch {
/* 忽略写入失败 */
}
}
/** 生成消息级反馈的持久化键:优先 turn_id,缺失时退回消息索引 */
function _feedbackKey(msg, msgIndex) {
const tid = msg && msg.meta && msg.meta.turn_id;
if (tid) return `${state.sessionId}:turn:${tid}:assistant`;
return `${state.sessionId}:idx:${msgIndex}:assistant`;
}
/** 重试:回退到本轮的用户消息(含该消息),恢复输入后重新发送 */
async function handleRetry(wrap) {
if (state.sending) {
showToast("Agent 运行中,无法重试。请先暂停任务。");
return;
}
if (!state.sessionId) {
showToast("无当前会话");
return;
}
// 找到本轮对应的用户消息(当前回复之前最近的一条 user 消息)
let prev = wrap.previousElementSibling;
while (prev && !prev.classList.contains("user")) {
prev = prev.previousElementSibling;
}
if (!prev) {
showToast("未找到对应的用户消息");
return;
}
if (!chatMessages) return;
const children = Array.from(chatMessages.children);
const userIdx = children.indexOf(prev);
if (userIdx < 0) return;
// 撤回该轮(删除本轮用户消息及其后的记录,内容恢复到输入区)
let rolledBack = false;
if (rollbackChatFn) {
rolledBack = await rollbackChatFn(userIdx, { skipConfirm: true });
}
if (!rolledBack) return;
// 撤回成功后重新执行 agent(相当于撤回 + 发送)
const textInput = $("textInput");
const { sendChat } = await import("./send.js");
if (textInput && textInput.value.trim()) {
sendChat();
} else {
showToast("已撤回本轮消息,请在输入框确认内容后重新发送");
}
}
/* ===== 聊天区文字引用(引用为"用户引用",复用 ref-chip UI) ===== */
let _quoteSeq = 0;
/** 将选中的聊天文字添加为引用 chip(发送时以「用户引用」形式注入文本) */
function addTextQuote(text) {
const clean = String(text || "").trim();
if (!clean) {
showToast("请先选中要引用的文字");
return;
}
const id = "quote:" + Date.now().toString(36) + ":" + (_quoteSeq++).toString(36);
state.pendingRefs.push({ type: "quote", path: id, text: clean });
state.pendingFiles.push({
__isRef: true,
refPath: id,
refName: clean.length > 28 ? clean.slice(0, 28) + "…" : clean,
type: "ref/quote",
name: "引用",
refText: clean,
});
renderAttachmentChips();
const textInput = $("textInput");
if (textInput) textInput.focus();
showToast("已添加引用");
}
let _quoteMenuCloseBound = null;
function removeQuoteMenu() {
const m = document.querySelector(".chat-quote-menu");
if (m) m.remove();
// 清理文档级关闭监听,避免旧菜单的监听误删新菜单
if (_quoteMenuCloseBound) {
document.removeEventListener("mousedown", _quoteMenuCloseBound);
_quoteMenuCloseBound = null;
}
}
/** 初始化聊天区引用:右键选中文字显示「引用」菜单 + 选中文字拖拽到输入区 */
function initChatQuoteMenu() {
if (!chatMessages) return;
// 左键选中文字后右键 → 显示"引用"菜单
chatMessages.addEventListener("contextmenu", (e) => {
const sel = window.getSelection();
const text = sel ? sel.toString().trim() : "";
if (!text) return; // 无选中内容时保留默认右键菜单
if (!chatMessages.contains(sel.anchorNode)) return;
e.preventDefault();
removeQuoteMenu();
const menu = document.createElement("div");
menu.className = "doc-context-menu chat-quote-menu";
// 限制菜单在视口内,避免靠右/靠底时溢出不可点击
const menuWidth = 168; // 约等于 .doc-context-menu min-width(160) + 边距
const menuHeight = 48; // 单个菜单项的高度余量
const posX = Math.max(4, Math.min(e.clientX, window.innerWidth - menuWidth));
const posY = Math.max(4, Math.min(e.clientY, window.innerHeight - menuHeight));
menu.style.left = posX + "px";
menu.style.top = posY + "px";
const item = document.createElement("div");
item.className = "doc-context-menu-item";
item.textContent = "引用所选文字";
item.addEventListener("click", () => {
addTextQuote(text);
removeQuoteMenu();
});
menu.appendChild(item);
document.body.appendChild(menu);
const close = (ev) => {
// 点击菜单项时 item 的 click 处理器先执行(移除菜单),此处检测到已移除则跳过
if (menu.contains(ev.target)) return;
removeQuoteMenu();
};
_quoteMenuCloseBound = close;
setTimeout(() => document.addEventListener("click", close), 0);
});
// 左键选中文字后拖拽到输入区
chatMessages.addEventListener("dragstart", (e) => {
// 图片 / 文件 / 文档引用拖拽走各自的自定义数据,不干扰
if (
e.dataTransfer.types.includes("application/x-chat-image") ||
e.dataTransfer.types.includes("application/x-chat-file") ||
e.dataTransfer.types.includes("application/doc-path")
) return;
const sel = window.getSelection();
const text = sel ? sel.toString().trim() : "";
if (!text) return;
if (!chatMessages.contains(sel.anchorNode)) return;
e.dataTransfer.setData("application/x-chat-quote", text);
e.dataTransfer.setData("text/plain", text);
e.dataTransfer.effectAllowed = "copy";
});
}
function renderMessages(messages, allowAutoplay = false) {
if (!chatMessages || !chatPlaceholder) return;
const list = messages || [];
@@ -1110,6 +1471,9 @@ async function streamAssistantMessage(msg) {
// pending_action 不再内嵌在气泡中,由 pending-overlay 浮窗展示
}
// 操作按钮行(复制 / 重试)
wrap.appendChild(renderMessageActions("assistant", msg, wrap));
scrollChatToBottom();
}
@@ -1149,4 +1513,5 @@ export {
appendThinkingIndicator, showPendingActionInThinking,
streamAssistantMessage,
renderVoiceTranscribingPlaceholder,
addTextQuote, initChatQuoteMenu,
};