fix: improve codex tool call live updates
This commit is contained in:
@@ -334,6 +334,26 @@ function createAgentRuntime(deps) {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'item.updated': {
|
||||
const item = event.item;
|
||||
if (!item || !item.id || item.type === 'agent_message') break;
|
||||
const tc = ensureCodexToolCall(entry, item);
|
||||
const resultText = codexToolResult(item).slice(0, 2000);
|
||||
tc.done = false;
|
||||
tc.result = resultText;
|
||||
|
||||
wsSend(entry.ws, {
|
||||
type: 'tool_update',
|
||||
toolUseId: item.id,
|
||||
name: tc.name,
|
||||
input: tc.input,
|
||||
result: resultText,
|
||||
kind: tc.kind,
|
||||
meta: tc.meta,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'item.completed': {
|
||||
const item = event.item;
|
||||
if (!item || !item.id) break;
|
||||
@@ -361,6 +381,7 @@ function createAgentRuntime(deps) {
|
||||
const resultText = codexToolResult(item).slice(0, 2000);
|
||||
tc.done = true;
|
||||
tc.result = resultText;
|
||||
|
||||
wsSend(entry.ws, {
|
||||
type: 'tool_end',
|
||||
toolUseId: item.id,
|
||||
|
||||
225
public/app.js
225
public/app.js
@@ -84,6 +84,8 @@
|
||||
let pendingText = '';
|
||||
let renderTimer = null;
|
||||
let activeToolCalls = new Map();
|
||||
let activeTodoCallTargets = new Map();
|
||||
let toolDomSeq = 0;
|
||||
let toolGroupCount = 0; // 当前 .msg-tools 直接子节点数(含已有父目录)
|
||||
let hasGrouped = false; // 本次输出是否已触发过折叠
|
||||
let cmdMenuIndex = -1;
|
||||
@@ -1365,6 +1367,7 @@
|
||||
pendingAttachments = [];
|
||||
uploadingAttachments = [];
|
||||
activeToolCalls.clear();
|
||||
activeTodoCallTargets.clear();
|
||||
sendBtn.hidden = false;
|
||||
abortBtn.hidden = true;
|
||||
chatTitle.textContent = '新会话';
|
||||
@@ -1387,6 +1390,7 @@
|
||||
abortBtn.hidden = true;
|
||||
pendingText = '';
|
||||
activeToolCalls.clear();
|
||||
activeTodoCallTargets.clear();
|
||||
}
|
||||
currentSessionId = snapshot.sessionId;
|
||||
loadedHistorySessionId = snapshot.sessionId;
|
||||
@@ -1814,6 +1818,27 @@
|
||||
appendToolCall(msg.toolUseId, msg.name, msg.input, false, msg.kind || null, msg.meta || null);
|
||||
break;
|
||||
|
||||
case 'tool_update':
|
||||
if (!isGenerating) startGenerating();
|
||||
if (!activeToolCalls.has(msg.toolUseId)) {
|
||||
activeToolCalls.set(msg.toolUseId, {
|
||||
name: msg.name,
|
||||
input: msg.input,
|
||||
kind: msg.kind || null,
|
||||
meta: msg.meta || null,
|
||||
done: false,
|
||||
});
|
||||
appendToolCall(msg.toolUseId, msg.name, msg.input, false, msg.kind || null, msg.meta || null);
|
||||
}
|
||||
activeToolCalls.get(msg.toolUseId).done = false;
|
||||
if (msg.name) activeToolCalls.get(msg.toolUseId).name = msg.name;
|
||||
if (msg.input !== undefined) activeToolCalls.get(msg.toolUseId).input = msg.input;
|
||||
if (msg.kind) activeToolCalls.get(msg.toolUseId).kind = msg.kind;
|
||||
if (msg.meta) activeToolCalls.get(msg.toolUseId).meta = msg.meta;
|
||||
activeToolCalls.get(msg.toolUseId).result = msg.result;
|
||||
updateToolCall(msg.toolUseId, msg.result, false);
|
||||
break;
|
||||
|
||||
case 'tool_end':
|
||||
if (activeToolCalls.has(msg.toolUseId)) {
|
||||
activeToolCalls.get(msg.toolUseId).done = true;
|
||||
@@ -1821,7 +1846,7 @@
|
||||
if (msg.meta) activeToolCalls.get(msg.toolUseId).meta = msg.meta;
|
||||
activeToolCalls.get(msg.toolUseId).result = msg.result;
|
||||
}
|
||||
updateToolCall(msg.toolUseId, msg.result);
|
||||
updateToolCall(msg.toolUseId, msg.result, true);
|
||||
break;
|
||||
|
||||
case 'cost':
|
||||
@@ -1880,6 +1905,7 @@
|
||||
toolGroupCount = 0;
|
||||
hasGrouped = false;
|
||||
activeToolCalls.clear();
|
||||
activeTodoCallTargets.clear();
|
||||
const toolsDiv = document.querySelector('#streaming-msg .msg-tools');
|
||||
if (toolsDiv) toolsDiv.innerHTML = '';
|
||||
}
|
||||
@@ -1896,8 +1922,8 @@
|
||||
done: tc.done,
|
||||
});
|
||||
appendToolCall(tc.id, tc.name, tc.input, tc.done, tc.kind || null, tc.meta || null);
|
||||
if (tc.done && tc.result) {
|
||||
updateToolCall(tc.id, tc.result);
|
||||
if (tc.result !== undefined && tc.result !== null) {
|
||||
updateToolCall(tc.id, tc.result, !!tc.done);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2015,6 +2041,7 @@
|
||||
pendingText = '';
|
||||
window.pendingContentBlocks = [];
|
||||
activeToolCalls.clear();
|
||||
activeTodoCallTargets.clear();
|
||||
toolGroupCount = 0;
|
||||
hasGrouped = false;
|
||||
sendBtn.hidden = true;
|
||||
@@ -2062,7 +2089,7 @@
|
||||
if (hasGrouped) {
|
||||
const toolsDiv = streamEl.querySelector('.msg-tools');
|
||||
if (toolsDiv) {
|
||||
const loose = Array.from(toolsDiv.children).filter(c => c.classList.contains('tool-call'));
|
||||
const loose = Array.from(toolsDiv.children).filter(isGroupableToolCall);
|
||||
if (loose.length > 0) {
|
||||
let group = toolsDiv.querySelector(':scope > .tool-group');
|
||||
if (!group) {
|
||||
@@ -2088,6 +2115,7 @@
|
||||
if (sessionId) currentSessionId = sessionId;
|
||||
pendingText = '';
|
||||
activeToolCalls.clear();
|
||||
activeTodoCallTargets.clear();
|
||||
toolGroupCount = 0;
|
||||
hasGrouped = false;
|
||||
}
|
||||
@@ -2197,7 +2225,7 @@
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// 尝试直接解析 JSON
|
||||
@@ -2209,7 +2237,7 @@
|
||||
bubble.appendChild(createTodoListElement(parsed));
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
} catch (e) {}
|
||||
}
|
||||
bubble.innerHTML = renderMarkdown(content);
|
||||
return;
|
||||
@@ -2270,17 +2298,52 @@
|
||||
return tool?.kind || tool?.meta?.kind || '';
|
||||
}
|
||||
|
||||
function toolTitle(tool) {
|
||||
if (tool?.meta?.title) return tool.meta.title;
|
||||
if (toolKind(tool) === 'file_change') {
|
||||
const filePath = tool?.meta?.subtitle || tool?.input?.file_path || '';
|
||||
const action = tool?.input?.new_string && tool?.input?.old_string ? '更新' : '创建';
|
||||
return filePath ? `${action} ${filePath}` : 'File Change';
|
||||
function normalizeDisplayPath(filePath) {
|
||||
const rawPath = typeof filePath === 'string' ? filePath.trim() : '';
|
||||
if (!rawPath) return '';
|
||||
const normalizedPath = rawPath.replace(/\\/g, '/');
|
||||
const normalizedCwd = typeof currentCwd === 'string' ? currentCwd.trim().replace(/\\/g, '/') : '';
|
||||
if (normalizedCwd && normalizedPath.startsWith(`${normalizedCwd}/`)) {
|
||||
return normalizedPath.slice(normalizedCwd.length + 1);
|
||||
}
|
||||
return normalizedPath;
|
||||
}
|
||||
|
||||
function getFileChangeDisplay(tool) {
|
||||
const changes = Array.isArray(tool?.input?.changes) ? tool.input.changes : [];
|
||||
const primaryChange = changes[0] || null;
|
||||
const rawPath = tool?.meta?.subtitle || primaryChange?.path || tool?.input?.path || tool?.input?.file_path || '';
|
||||
const filePath = normalizeDisplayPath(rawPath);
|
||||
const rawKind = primaryChange?.kind || tool?.input?.kind || '';
|
||||
let action = '修改';
|
||||
if (rawKind === 'create' || rawKind === 'new') {
|
||||
action = '创建';
|
||||
} else if (rawKind === 'delete' || rawKind === 'remove') {
|
||||
action = '删除';
|
||||
} else if (rawKind === 'update' || rawKind === 'edit') {
|
||||
action = '更新';
|
||||
} else if (tool?.input?.new_string && tool?.input?.old_string) {
|
||||
action = '更新';
|
||||
}
|
||||
return { action, filePath, changeCount: changes.length };
|
||||
}
|
||||
|
||||
function toolTitle(tool) {
|
||||
if (toolKind(tool) === 'file_change') {
|
||||
const { action, filePath, changeCount } = getFileChangeDisplay(tool);
|
||||
if (filePath && changeCount > 1) return `${action} ${filePath} 等 ${changeCount} 个文件`;
|
||||
if (filePath) return `${action} ${filePath}`;
|
||||
if (changeCount > 1) return `修改 ${changeCount} 个文件`;
|
||||
return tool?.meta?.title || 'File Change';
|
||||
}
|
||||
if (tool?.meta?.title) return tool.meta.title;
|
||||
return tool?.name || 'Tool';
|
||||
}
|
||||
|
||||
function toolSubtitle(tool) {
|
||||
if (toolKind(tool) === 'file_change') {
|
||||
return '';
|
||||
}
|
||||
if (tool?.meta?.subtitle) return tool.meta.subtitle;
|
||||
if (toolKind(tool) === 'command_execution') {
|
||||
return tool?.input?.command || '';
|
||||
@@ -2357,6 +2420,33 @@
|
||||
return section;
|
||||
}
|
||||
|
||||
function isGroupableToolCall(node) {
|
||||
return !!(node?.classList?.contains('tool-call') && node.dataset.toolKind !== 'todo_list');
|
||||
}
|
||||
|
||||
function rememberToolCallTarget(toolUseId, tool, element) {
|
||||
if (!element) return;
|
||||
const entry = activeToolCalls.get(toolUseId);
|
||||
if (entry) {
|
||||
entry.domElement = element;
|
||||
}
|
||||
if (toolKind(tool) === 'todo_list' && tool?.input?.id) {
|
||||
activeTodoCallTargets.set(tool.input.id, element);
|
||||
}
|
||||
}
|
||||
|
||||
function getLatestAssistantToolScope() {
|
||||
const streamEl = document.getElementById('streaming-msg');
|
||||
if (streamEl) return streamEl;
|
||||
const agentSelector = `.msg.assistant.agent-${normalizeAgent(currentAgent)}`;
|
||||
const assistantMessages = messagesDiv.querySelectorAll(agentSelector);
|
||||
if (assistantMessages.length > 0) {
|
||||
return assistantMessages[assistantMessages.length - 1];
|
||||
}
|
||||
const fallbackMessages = messagesDiv.querySelectorAll('.msg.assistant');
|
||||
return fallbackMessages.length > 0 ? fallbackMessages[fallbackMessages.length - 1] : null;
|
||||
}
|
||||
|
||||
function buildMsgElement(m) {
|
||||
const el = createMsgElement(m.role, m.content, m.attachments || []);
|
||||
if (m.role === 'assistant' && m.toolCalls && m.toolCalls.length > 0) {
|
||||
@@ -2367,7 +2457,7 @@
|
||||
const details = createToolCallElement(tc.id || `saved-${Math.random().toString(36).slice(2)}`, tc, true);
|
||||
|
||||
// 散落的 .tool-call 达到 FOLD_AT 个时,移入唯一 .tool-group
|
||||
const loose = Array.from(bubble.children).filter(c => c.classList.contains('tool-call'));
|
||||
const loose = Array.from(bubble.children).filter(isGroupableToolCall);
|
||||
if (loose.length >= FOLD_AT) {
|
||||
let group = bubble.querySelector(':scope > .tool-group');
|
||||
if (!group) {
|
||||
@@ -2390,7 +2480,7 @@
|
||||
}
|
||||
// 结束时若出现过父目录,收尾散落项
|
||||
if (grouped) {
|
||||
const loose = Array.from(bubble.children).filter(c => c.classList.contains('tool-call'));
|
||||
const loose = Array.from(bubble.children).filter(isGroupableToolCall);
|
||||
if (loose.length > 0) {
|
||||
const group = bubble.querySelector(':scope > .tool-group');
|
||||
if (group) {
|
||||
@@ -2633,6 +2723,23 @@
|
||||
const effectiveInput = tool.input !== undefined ? tool.input : input;
|
||||
const effectiveResult = tool.result;
|
||||
const kind = toolKind(tool);
|
||||
|
||||
if (kind === 'todo_list') {
|
||||
let todoData = effectiveInput;
|
||||
// 如果有 result 且是字符串,尝试解析
|
||||
if (effectiveResult && typeof effectiveResult === 'string') {
|
||||
try {
|
||||
todoData = JSON.parse(effectiveResult);
|
||||
} catch (e) {
|
||||
}
|
||||
} else {
|
||||
}
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'tool-call-content todo-list-content';
|
||||
wrapper.appendChild(createTodoListElement(todoData && typeof todoData === 'object' ? todoData : { items: [] }));
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
if (effectiveName === 'AskUserQuestion') {
|
||||
const questions = extractAskUserQuestions(effectiveInput);
|
||||
if (questions.length > 0) {
|
||||
@@ -2693,7 +2800,8 @@
|
||||
function createToolCallElement(toolUseId, tool, done) {
|
||||
const details = document.createElement('details');
|
||||
details.className = 'tool-call';
|
||||
details.id = `tool-${toolUseId}`;
|
||||
details.id = `tool-node-${++toolDomSeq}`;
|
||||
details.dataset.toolUseId = toolUseId ? String(toolUseId) : '';
|
||||
details.dataset.toolName = tool.name || '';
|
||||
if (toolKind(tool)) {
|
||||
details.dataset.toolKind = toolKind(tool);
|
||||
@@ -2728,12 +2836,24 @@
|
||||
|
||||
const tool = { id: toolUseId, name, input, kind, meta, done };
|
||||
|
||||
// 如果是 todo_list,检查是否已存在相同 id 的 todo_list
|
||||
if (kind === 'todo_list' && input?.id) {
|
||||
const existingTodo = findTodoToolCallByTodoId(toolsDiv, input.id);
|
||||
if (existingTodo) {
|
||||
const details = createToolCallElement(toolUseId, tool, done);
|
||||
existingTodo.replaceWith(details);
|
||||
rememberToolCallTarget(toolUseId, tool, details);
|
||||
scrollToBottom();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const details = createToolCallElement(toolUseId, tool, done);
|
||||
|
||||
// 折叠策略:只维护唯一一个 .tool-group 父节点
|
||||
// 散落的 .tool-call 直接子节点达到3个时,将它们全部移入父节点;之后继续散落,再达3个再移入
|
||||
const FOLD_AT = 3;
|
||||
const looseBefore = Array.from(toolsDiv.children).filter(c => c.classList.contains('tool-call'));
|
||||
const looseBefore = Array.from(toolsDiv.children).filter(isGroupableToolCall);
|
||||
if (looseBefore.length >= FOLD_AT) {
|
||||
// 确保存在唯一的 .tool-group
|
||||
let group = toolsDiv.querySelector(':scope > .tool-group');
|
||||
@@ -2754,6 +2874,7 @@
|
||||
_refreshGroupSummary(group);
|
||||
}
|
||||
toolsDiv.appendChild(details);
|
||||
rememberToolCallTarget(toolUseId, tool, details);
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
@@ -2764,23 +2885,71 @@
|
||||
if (summary) summary.textContent = `展开 ${count} 个工具调用`;
|
||||
}
|
||||
|
||||
function updateToolCall(toolUseId, result) {
|
||||
const el = document.getElementById(`tool-${toolUseId}`);
|
||||
if (!el) return;
|
||||
const tool = activeToolCalls.get(toolUseId) || {
|
||||
function findLatestToolCallElement(root, matcher) {
|
||||
if (!root || typeof matcher !== 'function') return null;
|
||||
const allTools = root.querySelectorAll('.tool-call');
|
||||
for (let i = allTools.length - 1; i >= 0; i--) {
|
||||
const el = allTools[i];
|
||||
if (matcher(el)) return el;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findTodoToolCallByTodoId(root, todoId) {
|
||||
if (!todoId) return null;
|
||||
return findLatestToolCallElement(root, (el) => {
|
||||
if (el.dataset.toolKind !== 'todo_list') return false;
|
||||
const currentTodoId = el.querySelector('.todo-list-container')?.dataset.todoId;
|
||||
return currentTodoId === todoId;
|
||||
});
|
||||
}
|
||||
|
||||
function updateToolCall(toolUseId, result, done = true) {
|
||||
const tool = activeToolCalls.get(toolUseId) || null;
|
||||
const toolUseIdText = toolUseId ? String(toolUseId) : '';
|
||||
const scope = getLatestAssistantToolScope();
|
||||
let el = tool?.domElement && tool.domElement.isConnected ? tool.domElement : null;
|
||||
|
||||
if (!el) {
|
||||
if (tool?.kind === 'todo_list' && tool?.input?.id) {
|
||||
const markedTodo = activeTodoCallTargets.get(tool.input.id);
|
||||
if (markedTodo && markedTodo.isConnected) {
|
||||
el = markedTodo;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!el) {
|
||||
el = findLatestToolCallElement(scope, (candidate) => candidate.dataset.toolUseId === toolUseIdText);
|
||||
}
|
||||
|
||||
if (!el && tool?.kind === 'todo_list' && tool?.input?.id) {
|
||||
el = findTodoToolCallByTodoId(scope, tool.input.id);
|
||||
}
|
||||
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextTool = tool || {
|
||||
id: toolUseId,
|
||||
name: el.dataset.toolName || '',
|
||||
kind: el.dataset.toolKind || null,
|
||||
done: true,
|
||||
done,
|
||||
};
|
||||
tool.done = true;
|
||||
if (result !== undefined) tool.result = result;
|
||||
nextTool.done = done;
|
||||
if (result !== undefined) nextTool.result = result;
|
||||
rememberToolCallTarget(toolUseId, nextTool, el);
|
||||
const summary = el.querySelector('summary');
|
||||
if (summary) applyToolSummary(summary, tool, true);
|
||||
if (tool.name === 'AskUserQuestion') return;
|
||||
const nextContent = buildToolContentElement(tool);
|
||||
const content = el.querySelector('.tool-call-content');
|
||||
if (content) content.replaceWith(nextContent);
|
||||
if (summary) applyToolSummary(summary, nextTool, done);
|
||||
if (nextTool.name === 'AskUserQuestion') return;
|
||||
const nextContent = buildToolContentElement(nextTool);
|
||||
const content = Array.from(el.children).find((child) => child.tagName !== 'SUMMARY') || null;
|
||||
if (content) {
|
||||
content.replaceWith(nextContent);
|
||||
} else {
|
||||
el.appendChild(nextContent);
|
||||
}
|
||||
}
|
||||
|
||||
function getDeleteConfirmMessage(agent) {
|
||||
|
||||
@@ -1221,7 +1221,7 @@ body.session-loading-active {
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-secondary);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
align-items: anchor-center;
|
||||
gap: 8px;
|
||||
user-select: none;
|
||||
list-style: none;
|
||||
@@ -1313,6 +1313,14 @@ body.session-loading-active {
|
||||
color: var(--text-primary);
|
||||
background: linear-gradient(180deg, rgba(255, 249, 242, 0.92), rgba(245, 221, 212, 0.32));
|
||||
}
|
||||
.tool-call-content.todo-list-content {
|
||||
font-family: inherit;
|
||||
white-space: normal;
|
||||
word-break: normal;
|
||||
}
|
||||
.tool-call-content.todo-list-content .todo-list-container {
|
||||
margin: 0;
|
||||
}
|
||||
.tool-call-content.command,
|
||||
.tool-call-content.file-change {
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(250, 246, 240, 0.92));
|
||||
@@ -1631,7 +1639,7 @@ body.session-loading-active {
|
||||
}
|
||||
.input-wrapper {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
align-items: anchor-center;
|
||||
gap: 8px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--border-color);
|
||||
|
||||
Reference in New Issue
Block a user