feat: support MCP elicitation and rebuild release
This commit is contained in:
195
public/app.js
195
public/app.js
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user