feat: support MCP elicitation and rebuild release

This commit is contained in:
shiyue
2026-08-24 17:39:41 +08:00
parent dd233a40e8
commit bd20a79d4b
69 changed files with 8978 additions and 13 deletions

View File

@@ -256,6 +256,7 @@
let directoryPickerState = null;
let codexAppUserInputModal = null;
let codexAppApprovalModal = null;
let codexAppElicitationModal = null;
let pendingNewSessionRequest = null;
let pendingSessionSwitchRequest = null;
let pendingSessionResumeRequest = null;
@@ -1462,6 +1463,30 @@
return sessions.find((s) => s.id === sessionId) || null;
}
function normalizeGiteaSource(source) {
if (!source || typeof source !== 'object' || Array.isArray(source)) return null;
const repository = String(source.repository || source.repo || '').trim().slice(0, 240);
const resourceKind = String(source.resourceKind || source.kind || '').trim().toLowerCase();
const number = Number(source.number);
const title = String(source.title || '').replace(/\s+/g, ' ').trim().slice(0, 240);
if (!repository && (!Number.isSafeInteger(number) || number <= 0) && !title) return null;
return {
source: 'gitea',
label: 'Gitea',
repository,
resourceKind: resourceKind === 'pull_request' || resourceKind === 'pr' ? 'pull_request' : 'issue',
number: Number.isSafeInteger(number) && number > 0 ? number : null,
title,
};
}
function giteaResourceLabel(source) {
const normalized = normalizeGiteaSource(source);
if (!normalized) return 'Gitea';
const kind = normalized.resourceKind === 'pull_request' ? 'PR' : 'Issue';
return [normalized.repository, normalized.number ? `${kind} #${normalized.number}` : ''].filter(Boolean).join(' · ') || 'Gitea';
}
function compareSessionUpdatedDesc(a, b) {
return new Date(b?.updated || 0) - new Date(a?.updated || 0);
}
@@ -2194,6 +2219,7 @@
pinnedAt: payload.pinnedAt || null,
titleSource: payload.titleSource || null,
createdFromKind: payload.createdFromKind || null,
giteaSource: normalizeGiteaSource(payload.giteaSource),
hasUnread: !!payload.hasUnread,
cwd: payload.cwd || null,
projectName: payload.projectName || '',
@@ -2310,6 +2336,7 @@
pinnedAt: snapshot.pinnedAt || null,
titleSource: snapshot.titleSource || null,
createdFromKind: snapshot.createdFromKind || null,
giteaSource: normalizeGiteaSource(snapshot.giteaSource),
hasUnread: !!snapshot.hasUnread,
isRunning: !!snapshot.isRunning,
waitingOnChildren: !!snapshot.waitingOnChildren,
@@ -2336,6 +2363,7 @@
title: nextMeta.title || session.title,
titleSource: nextMeta.titleSource || session.titleSource || null,
createdFromKind: nextMeta.createdFromKind || session.createdFromKind || null,
giteaSource: nextMeta.giteaSource || session.giteaSource || null,
};
});
if (!found) {
@@ -2364,6 +2392,7 @@
snapshot.hasUnread = !!meta.hasUnread;
snapshot.updated = meta.updated || snapshot.updated;
snapshot.pinnedAt = meta.pinnedAt || null;
snapshot.giteaSource = normalizeGiteaSource(meta.giteaSource);
snapshot.isRunning = !!meta.isRunning;
snapshot.waitingOnChildren = !!meta.waitingOnChildren;
snapshot.pendingReplyCount = Number(meta.pendingReplyCount || 0);
@@ -2684,6 +2713,124 @@
overlay.querySelector('[data-codex-approval-action="approve"]')?.focus();
}
function closeCodexAppElicitationModal(sendCancel = false) {
if (!codexAppElicitationModal) return;
const { overlay, escapeHandler, requestId, sessionId } = codexAppElicitationModal;
if (escapeHandler) document.removeEventListener('keydown', escapeHandler);
if (overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay);
codexAppElicitationModal = null;
if (sendCancel && requestId) {
send({
type: 'codex_app_elicitation_response',
action: 'cancel',
sessionId,
requestId,
content: null,
_meta: null,
});
}
}
function submitCodexAppElicitation(action) {
if (!codexAppElicitationModal) return;
const { requestId, sessionId, panel, fields, mode } = codexAppElicitationModal;
const content = action === 'accept' && mode !== 'url' && panel ? collectCodexAppElicitationContent(panel, fields) : null;
send({
type: 'codex_app_elicitation_response',
action,
sessionId,
requestId,
content,
_meta: null,
});
closeCodexAppElicitationModal(false);
}
function codexAppElicitationOptions(schema = {}) {
if (Array.isArray(schema.enum)) return schema.enum.map((value) => ({ value, label: value }));
if (Array.isArray(schema.oneOf)) {
return schema.oneOf
.filter((item) => item && Object.prototype.hasOwnProperty.call(item, 'const'))
.map((item) => ({ value: item.const, label: item.title || item.const }));
}
return [];
}
function renderCodexAppElicitationField(id, schema = {}, required = false) {
const type = String(schema.type || 'string');
const options = codexAppElicitationOptions(schema);
const escapedId = escapeHtml(id);
const title = schema.title || id;
const description = schema.description ? `<span class="codex-elicitation-desc">${escapeHtml(schema.description)}</span>` : '';
let control = '';
if (type === 'boolean') {
control = `<label class="codex-elicitation-check"><input type="checkbox" data-codex-elicit-field="${escapedId}" data-codex-elicit-type="boolean"${schema.default === true ? ' checked' : ''}><span>是</span></label>`;
} else if (type === 'array' && (schema.items?.enum || schema.items?.anyOf)) {
const itemOptions = codexAppElicitationOptions(schema.items || {});
control = itemOptions.map((option, index) => `<label class="codex-elicitation-check"><input type="checkbox" data-codex-elicit-field="${escapedId}" data-codex-elicit-type="array" value="${escapeHtml(option.value)}"${Array.isArray(schema.default) && schema.default.includes(option.value) ? ' checked' : ''}><span>${escapeHtml(option.label || option.value)}</span></label>`).join('');
} else if (options.length > 0) {
control = `<select class="codex-elicitation-input" data-codex-elicit-field="${escapedId}" data-codex-elicit-type="${escapeHtml(type)}">${options.map((option) => `<option value="${escapeHtml(option.value)}"${String(schema.default) === String(option.value) ? ' selected' : ''}>${escapeHtml(option.label || option.value)}</option>`).join('')}</select>`;
} else {
const inputType = type === 'number' || type === 'integer' ? 'number' : (schema.format === 'email' ? 'email' : (schema.format === 'date' ? 'date' : (schema.format === 'date-time' ? 'datetime-local' : (schema.format === 'uri' ? 'url' : 'text'))));
const isLong = type === 'object' || schema.format === 'multiline' || Number(schema.maxLength || 0) > 180;
const value = schema.default === undefined ? '' : (typeof schema.default === 'string' ? schema.default : JSON.stringify(schema.default));
control = isLong
? `<textarea class="codex-elicitation-input" data-codex-elicit-field="${escapedId}" data-codex-elicit-type="${escapedId ? escapeHtml(type) : 'string'}"${required ? ' required' : ''}>${escapeHtml(value)}</textarea>`
: `<input class="codex-elicitation-input" type="${inputType}" data-codex-elicit-field="${escapedId}" data-codex-elicit-type="${escapeHtml(type)}" value="${escapeHtml(value)}"${required ? ' required' : ''}${schema.minLength ? ` minlength="${Number(schema.minLength)}"` : ''}${schema.maxLength ? ` maxlength="${Number(schema.maxLength)}"` : ''}${schema.minimum !== undefined ? ` min="${Number(schema.minimum)}"` : ''}${schema.maximum !== undefined ? ` max="${Number(schema.maximum)}"` : ''}>`;
}
return `<label class="codex-elicitation-field"><span class="codex-elicitation-label">${escapeHtml(title)}${required ? ' *' : ''}</span>${description}<span class="codex-elicitation-control">${control}</span></label>`;
}
function collectCodexAppElicitationContent(panel, fields = []) {
const content = {};
for (const field of fields) {
const id = String(field.id || '').trim();
if (!id) continue;
const selector = `[data-codex-elicit-field="${cssEscape(id)}"]`;
const controls = Array.from(panel.querySelectorAll(selector));
if (!controls.length) continue;
const type = String(field.schema?.type || controls[0].dataset.codexElicitType || 'string');
if (type === 'boolean') {
content[id] = controls[0].checked;
} else if (type === 'array') {
content[id] = controls.filter((control) => control.checked).map((control) => control.value);
} else {
const value = controls[0].value;
if (type === 'number' || type === 'integer') content[id] = value === '' ? null : Number(value);
else if (type === 'object') {
try { content[id] = value ? JSON.parse(value) : {}; } catch { content[id] = value; }
} else content[id] = value;
}
}
return content;
}
function showCodexAppElicitationModal(msg) {
closeCodexAppElicitationModal(true);
const mode = String(msg.mode || 'form');
const schema = msg.requestedSchema && typeof msg.requestedSchema === 'object' ? msg.requestedSchema : {};
const safeUrl = /^https?:\/\//i.test(String(msg.url || '')) ? String(msg.url) : '';
const properties = schema.properties && typeof schema.properties === 'object' ? schema.properties : {};
const required = new Set(Array.isArray(schema.required) ? schema.required : []);
const fields = Object.entries(properties).map(([id, fieldSchema]) => ({ id, schema: fieldSchema || {} }));
const formHtml = mode === 'url'
? `<div class="codex-elicitation-url-copy">请在新页面完成 ${escapeHtml(msg.serverName || 'MCP 服务')} 的操作,然后返回此处确认。</div>${safeUrl ? `<a class="codex-elicitation-url" href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener noreferrer">打开授权页面</a>` : '<div class="modal-empty">MCP 未提供可打开的安全 URL。</div>'}`
: (fields.length ? fields.map((field) => renderCodexAppElicitationField(field.id, field.schema, required.has(field.id))).join('') : '<div class="modal-empty">MCP 没有提供可填写的字段。</div>');
const overlay = document.createElement('div');
overlay.className = 'modal-overlay codex-elicitation-overlay';
overlay.innerHTML = `<div class="modal-panel codex-elicitation-panel"><div class="modal-header"><span class="modal-title">${escapeHtml(msg.serverName ? `${msg.serverName} 请求信息` : 'MCP 请求信息')}</span><button class="modal-close-btn" type="button" data-codex-elicitation-cancel>✕</button></div><div class="modal-body codex-elicitation-body">${msg.message ? `<div class="codex-elicitation-message">${escapeHtml(msg.message)}</div>` : ''}<div class="codex-elicitation-mode">${escapeHtml(mode === 'url' ? '外部页面' : '表单')}</div>${formHtml}</div><div class="modal-footer codex-elicitation-footer"><button class="modal-btn-secondary" type="button" data-codex-elicitation-cancel>取消</button><button class="modal-btn-secondary codex-elicitation-decline" type="button" data-codex-elicitation-decline>拒绝</button><button class="modal-btn-primary" type="button" data-codex-elicitation-accept>接受并继续</button></div></div>`;
document.body.appendChild(overlay);
const panel = overlay.querySelector('.codex-elicitation-panel');
const escapeHandler = (event) => { if (event.key === 'Escape') closeCodexAppElicitationModal(true); };
document.addEventListener('keydown', escapeHandler);
codexAppElicitationModal = { overlay, panel, fields, mode, requestId: msg.requestId || '', sessionId: msg.sessionId || '', escapeHandler };
overlay.querySelectorAll('[data-codex-elicitation-cancel]').forEach((button) => button.addEventListener('click', () => closeCodexAppElicitationModal(true)));
overlay.addEventListener('click', (event) => { if (event.target === overlay) closeCodexAppElicitationModal(true); });
overlay.querySelector('[data-codex-elicitation-decline]')?.addEventListener('click', () => submitCodexAppElicitation('decline'));
overlay.querySelector('[data-codex-elicitation-accept]')?.addEventListener('click', () => submitCodexAppElicitation('accept'));
overlay.querySelector('input, select, textarea, button')?.focus();
}
function cssEscape(value) {
if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(String(value || ''));
return String(value || '').replace(/["\\]/g, '\\$&');
@@ -5703,15 +5850,17 @@
const item = document.createElement('div');
const isPinned = !!session.pinnedAt;
const isLlmCreated = String(session.createdFromKind || '').toLowerCase() === 'mcp';
const giteaSource = normalizeGiteaSource(session.giteaSource);
const waitingOnChildren = !!session.waitingOnChildren;
const readyReplyCount = Number(session.readyReplyCount || 0);
const waitingLabel = readyReplyCount > 0 ? `子对话已返回 ${readyReplyCount}` : `等待子对话 ${Number(session.pendingReplyCount || 0) || ''}`.trim();
item.className = `session-item${session.id === currentSessionId ? ' active' : ''}${isPinned ? ' pinned' : ''}${isLlmCreated ? ' llm-created' : ''}${waitingOnChildren ? ' waiting-children' : ''}`;
item.className = `session-item${session.id === currentSessionId ? ' active' : ''}${isPinned ? ' pinned' : ''}${isLlmCreated ? ' llm-created' : ''}${giteaSource ? ' gitea-session' : ''}${waitingOnChildren ? ' waiting-children' : ''}`;
item.dataset.id = session.id;
const sessionProjectName = getSessionProjectName(session);
item.title = buildSessionItemTooltip(sessionProjectName, session.title);
item.innerHTML = `
<div class="session-item-main">
${giteaSource ? `<span class="session-item-source-badge" title="${escapeHtml(giteaResourceLabel(giteaSource))}">Gitea</span>` : ''}
<span class="session-item-title">${escapeHtml(session.title || 'Untitled')}</span>
${isPinned ? '<span class="session-item-pin-badge" title="已置顶">顶</span>' : ''}
${session.isRunning ? '<span class="session-item-status">运行中</span>' : ''}
@@ -7220,11 +7369,13 @@
title: msg.title,
...(msg.titleSource !== undefined ? { titleSource: msg.titleSource || null } : {}),
...(msg.createdFromKind !== undefined ? { createdFromKind: msg.createdFromKind || null } : {}),
...(msg.giteaSource !== undefined ? { giteaSource: normalizeGiteaSource(msg.giteaSource) } : {}),
} : session);
updateCachedSession(msg.sessionId, (snapshot) => {
snapshot.title = msg.title;
if (msg.titleSource !== undefined) snapshot.titleSource = msg.titleSource || null;
if (msg.createdFromKind !== undefined) snapshot.createdFromKind = msg.createdFromKind || null;
if (msg.giteaSource !== undefined) snapshot.giteaSource = normalizeGiteaSource(msg.giteaSource);
if (msg.titleEvent) {
snapshot.titleHistory = normalizeOutlineTitleHistory([...(snapshot.titleHistory || []), msg.titleEvent]);
}
@@ -7367,6 +7518,13 @@
showCodexAppApprovalModal(msg);
break;
case 'codex_app_elicitation_request':
if (msg.sessionId && msg.sessionId !== currentSessionId) {
showToast('MCP 请求输入', msg.sessionId);
}
showCodexAppElicitationModal(msg);
break;
case 'ccweb_mcp_child_agent_update':
applyCcwebMcpChildAgentUpdate(msg);
break;
@@ -8002,14 +8160,30 @@
return wrap;
}
function getGiteaVisibleMessageContent(content) {
const raw = String(content || '').trim();
if (!raw) return '来自 gitea 消息:未提供具体指令,请读取工单上下文。';
if (/^来自\s*gitea\s*消息[:]/i.test(raw)) return raw;
// 兼容旧会话中已经保存的完整 Workflow Prompt只提取用户指令段。
const match = raw.match(/用户指令:\s*\n([\s\S]*?)(?:\n\n完成后必须使用官方 gitea-mcp|\n\n如果需要用户补充信息|$)/);
const instruction = String(match?.[1] || '').trim();
return `来自 gitea 消息:${instruction || '已收到工单请求,请读取工单上下文。'}`;
}
function createMsgElement(role, content, attachments = [], meta = {}) {
const div = document.createElement('div');
const isCrossConversation = !!meta.crossConversation;
const isCrossConversationReply = isCrossConversation && !!(meta.crossConversation.reply || meta.crossConversation.replyToRequestId);
const canCollapseCrossConversationReply = role === 'assistant' && isCrossConversationReply;
const isGoalMessage = role === 'user' && meta?.ccwebGoalCommand?.action === 'set';
const hasGiteaPrompt = role === 'user' && typeof content === 'string'
&& /你正在执行\s*cc-web\s*Gitea\s*Workflow\s*任务/i.test(content);
const giteaSource = normalizeGiteaSource(meta.giteaSource)
|| (hasGiteaPrompt ? { source: 'gitea', label: 'Gitea', repository: '', resourceKind: 'issue', number: null, title: '' } : null);
const isGiteaMessage = role === 'user' && !!giteaSource;
const visibleContent = isGiteaMessage ? getGiteaVisibleMessageContent(content) : content;
const resolvedMessageId = meta?.messageId || meta?.id || createLocalId('user');
div.className = `msg ${role}${role === 'assistant' ? ' agent-' + currentAgent : ''}${isCrossConversation ? ' cross-conversation' : ''}${isCrossConversationReply ? ' cross-conversation-reply' : ''}${isGoalMessage ? ' goal-message' : ''}`;
div.className = `msg ${role}${role === 'assistant' ? ' agent-' + currentAgent : ''}${isCrossConversation ? ' cross-conversation' : ''}${isCrossConversationReply ? ' cross-conversation-reply' : ''}${isGoalMessage ? ' goal-message' : ''}${isGiteaMessage ? ' gitea-message' : ''}`;
if (role === 'user') {
div.id = `hapi-message-${resolvedMessageId}`;
div.dataset.messageId = resolvedMessageId;
@@ -8139,6 +8313,14 @@
}
if (role === 'user') {
if (isGiteaMessage) {
const giteaLabel = document.createElement('div');
giteaLabel.className = 'gitea-message-label';
giteaLabel.textContent = 'Gitea';
giteaLabel.title = giteaResourceLabel(giteaSource);
giteaLabel.setAttribute('aria-label', `消息来源:${giteaResourceLabel(giteaSource)}`);
bubble.appendChild(giteaLabel);
}
if (isGoalMessage) {
const goalLabel = document.createElement('div');
goalLabel.className = 'goal-message-label';
@@ -8146,11 +8328,11 @@
goalLabel.setAttribute('aria-label', '目标模式');
bubble.appendChild(goalLabel);
}
if (content) {
if (visibleContent) {
const textNode = document.createElement('div');
textNode.className = 'msg-text';
textNode.style.whiteSpace = 'pre-wrap';
textNode.textContent = content;
textNode.textContent = visibleContent;
bubble.appendChild(textNode);
const copyBtn = document.createElement('button');
@@ -8166,7 +8348,7 @@
`;
copyBtn.addEventListener('click', (event) => {
event.stopPropagation();
copyTextToClipboard(content, '用户消息已复制');
copyTextToClipboard(visibleContent, '用户消息已复制');
});
bubble.appendChild(copyBtn);
}
@@ -8203,7 +8385,7 @@
setCodexAppSteerStatusElement(div, meta.codexAppSteerStatus, meta.codexAppSteerMessage);
}
if (role === 'user') {
registerUserMessage(resolvedMessageId, div, content, meta.timestamp);
registerUserMessage(resolvedMessageId, div, visibleContent, meta.timestamp);
}
return div;
}
@@ -10474,6 +10656,7 @@
active: session?.id === currentSessionId,
pinnedAt: session?.pinnedAt || '',
createdFromKind: String(session?.createdFromKind || '').toLowerCase(),
giteaSource: session?.giteaSource ? JSON.stringify(session.giteaSource) : '',
isRunning: !!session?.isRunning,
hasUnread: !!session?.hasUnread,
waitingOnChildren: !!session?.waitingOnChildren,