feat: improve codex app controls and recovery
This commit is contained in:
@@ -1,5 +1,10 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
const CODEX_APP_ONCE_NOTICE_PATTERNS = [
|
||||||
|
/^Under-development features enabled:/i,
|
||||||
|
/^Heads up: Long threads and multiple compactions/i,
|
||||||
|
];
|
||||||
|
|
||||||
function createCodexAppRuntime(deps = {}) {
|
function createCodexAppRuntime(deps = {}) {
|
||||||
const {
|
const {
|
||||||
wsSend,
|
wsSend,
|
||||||
@@ -14,6 +19,23 @@ function createCodexAppRuntime(deps = {}) {
|
|||||||
return text.length > maxLen ? `${text.slice(0, maxLen)}...` : value;
|
return text.length > maxLen ? `${text.slice(0, maxLen)}...` : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const shownOnceNoticeKeys = new Set();
|
||||||
|
|
||||||
|
function normalizeNoticeMessage(message) {
|
||||||
|
return String(message || '').trim().replace(/\s+/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldShowRuntimeNotice(method, message) {
|
||||||
|
const normalized = normalizeNoticeMessage(message);
|
||||||
|
const isOnceNotice = CODEX_APP_ONCE_NOTICE_PATTERNS.some((pattern) => pattern.test(normalized));
|
||||||
|
if (!isOnceNotice) return true;
|
||||||
|
|
||||||
|
const key = `${method}:${normalized}`;
|
||||||
|
if (shownOnceNoticeKeys.has(key)) return false;
|
||||||
|
shownOnceNoticeKeys.add(key);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function sendRuntime(entry, sessionId, payload) {
|
function sendRuntime(entry, sessionId, payload) {
|
||||||
wsSend(entry.ws, { ...payload, sessionId });
|
wsSend(entry.ws, { ...payload, sessionId });
|
||||||
}
|
}
|
||||||
@@ -483,7 +505,9 @@ function createCodexAppRuntime(deps = {}) {
|
|||||||
const message = params.message || params.title || '';
|
const message = params.message || params.title || '';
|
||||||
if (message) {
|
if (message) {
|
||||||
if (method === 'error') entry.lastError = message;
|
if (method === 'error') entry.lastError = message;
|
||||||
sendRuntime(entry, sessionId, { type: 'system_message', message });
|
if (method === 'error' || shouldShowRuntimeNotice(method, message)) {
|
||||||
|
sendRuntime(entry, sessionId, { type: 'system_message', message });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return { done: false };
|
return { done: false };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,6 +133,10 @@ function createCodexAppServerClient(options = {}) {
|
|||||||
sendRaw({ method, params });
|
sendRaw({ method, params });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function reloadMcpServers() {
|
||||||
|
return request('config/mcpServer/reload', {}, 30000);
|
||||||
|
}
|
||||||
|
|
||||||
function start() {
|
function start() {
|
||||||
if (initPromise) return initPromise;
|
if (initPromise) return initPromise;
|
||||||
exited = false;
|
exited = false;
|
||||||
@@ -212,6 +216,7 @@ function createCodexAppServerClient(options = {}) {
|
|||||||
stop,
|
stop,
|
||||||
request,
|
request,
|
||||||
notification,
|
notification,
|
||||||
|
reloadMcpServers,
|
||||||
isRunning,
|
isRunning,
|
||||||
pid: () => proc?.pid || null,
|
pid: () => proc?.pid || null,
|
||||||
};
|
};
|
||||||
|
|||||||
150
public/app.js
150
public/app.js
@@ -2,7 +2,7 @@
|
|||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const ASSET_VERSION = '20260614-divider-time-selectfix';
|
const ASSET_VERSION = '20260615-reload-mcp';
|
||||||
const WS_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`;
|
const WS_URL = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/ws`;
|
||||||
const RENDER_DEBOUNCE = 100;
|
const RENDER_DEBOUNCE = 100;
|
||||||
const COMPOSER_SUGGESTION_DEBOUNCE = 120;
|
const COMPOSER_SUGGESTION_DEBOUNCE = 120;
|
||||||
@@ -35,6 +35,9 @@
|
|||||||
const SESSION_CACHE_MAX_WEIGHT = 1_500_000;
|
const SESSION_CACHE_MAX_WEIGHT = 1_500_000;
|
||||||
const SIDEBAR_SWIPE_TRIGGER = 72;
|
const SIDEBAR_SWIPE_TRIGGER = 72;
|
||||||
const SIDEBAR_SWIPE_MAX_VERTICAL_DRIFT = 42;
|
const SIDEBAR_SWIPE_MAX_VERTICAL_DRIFT = 42;
|
||||||
|
const OLD_SESSION_COLLAPSE_VISIBLE_LIMIT = 5;
|
||||||
|
const OLD_SESSION_COLLAPSE_DAYS = 7;
|
||||||
|
const OLD_SESSION_COLLAPSE_MS = OLD_SESSION_COLLAPSE_DAYS * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
const MODEL_OPTIONS = [
|
const MODEL_OPTIONS = [
|
||||||
{ value: 'opus', label: 'Opus', desc: '最强大,1M 上下文' },
|
{ value: 'opus', label: 'Opus', desc: '最强大,1M 上下文' },
|
||||||
@@ -159,8 +162,10 @@
|
|||||||
let pendingInitialSessionLoad = false;
|
let pendingInitialSessionLoad = false;
|
||||||
let noteMode = false;
|
let noteMode = false;
|
||||||
let noteDraftSeq = 0;
|
let noteDraftSeq = 0;
|
||||||
|
let isReloadingMcp = false;
|
||||||
const pendingNotesByTarget = new Map();
|
const pendingNotesByTarget = new Map();
|
||||||
const userMessageIndex = new Map();
|
const userMessageIndex = new Map();
|
||||||
|
const expandedOldSessionAgents = new Set();
|
||||||
document.documentElement.dataset.dividerTime = showAgentDividerTime ? 'show' : 'hide';
|
document.documentElement.dataset.dividerTime = showAgentDividerTime ? 'show' : 'hide';
|
||||||
|
|
||||||
// --- DOM ---
|
// --- DOM ---
|
||||||
@@ -191,6 +196,7 @@
|
|||||||
const chatCwd = $('#chat-cwd');
|
const chatCwd = $('#chat-cwd');
|
||||||
const userOutlineBtn = $('#user-outline-btn');
|
const userOutlineBtn = $('#user-outline-btn');
|
||||||
const userOutlinePanel = $('#user-outline-panel');
|
const userOutlinePanel = $('#user-outline-panel');
|
||||||
|
const reloadMcpBtn = $('#reload-mcp-btn');
|
||||||
const costDisplay = $('#cost-display');
|
const costDisplay = $('#cost-display');
|
||||||
const attachmentTray = $('#attachment-tray');
|
const attachmentTray = $('#attachment-tray');
|
||||||
const pendingNotesTray = $('#pending-notes-tray');
|
const pendingNotesTray = $('#pending-notes-tray');
|
||||||
@@ -1087,6 +1093,32 @@
|
|||||||
return data || {};
|
return data || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateReloadMcpButtonUI() {
|
||||||
|
if (!reloadMcpBtn) return;
|
||||||
|
const visible = !!currentSessionId && isCodexAppAgent(currentAgent);
|
||||||
|
reloadMcpBtn.hidden = !visible;
|
||||||
|
reloadMcpBtn.disabled = !visible || isReloadingMcp;
|
||||||
|
reloadMcpBtn.textContent = isReloadingMcp ? '重载中' : '重载 MCP';
|
||||||
|
reloadMcpBtn.setAttribute('aria-busy', isReloadingMcp ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadCurrentMcpServers() {
|
||||||
|
if (!currentSessionId || !isCodexAppAgent(currentAgent) || isReloadingMcp) return;
|
||||||
|
isReloadingMcp = true;
|
||||||
|
updateReloadMcpButtonUI();
|
||||||
|
try {
|
||||||
|
await fetchAuthJson(`/api/sessions/${encodeURIComponent(currentSessionId)}/reload-mcp`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
showToast('已请求重载 MCP');
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err?.message || '重载 MCP 失败');
|
||||||
|
} finally {
|
||||||
|
isReloadingMcp = false;
|
||||||
|
updateReloadMcpButtonUI();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function closeCodexAppUserInputModal(sendCancel = false) {
|
function closeCodexAppUserInputModal(sendCancel = false) {
|
||||||
if (!codexAppUserInputModal) return;
|
if (!codexAppUserInputModal) return;
|
||||||
const { overlay, escapeHandler, requestId, sessionId } = codexAppUserInputModal;
|
const { overlay, escapeHandler, requestId, sessionId } = codexAppUserInputModal;
|
||||||
@@ -1096,6 +1128,7 @@
|
|||||||
if (sendCancel && requestId) {
|
if (sendCancel && requestId) {
|
||||||
send({
|
send({
|
||||||
type: 'codex_app_user_input_response',
|
type: 'codex_app_user_input_response',
|
||||||
|
action: 'cancel',
|
||||||
sessionId,
|
sessionId,
|
||||||
requestId,
|
requestId,
|
||||||
answers: {},
|
answers: {},
|
||||||
@@ -1219,6 +1252,7 @@
|
|||||||
overlay.querySelector('[data-codex-ui-submit]')?.addEventListener('click', () => {
|
overlay.querySelector('[data-codex-ui-submit]')?.addEventListener('click', () => {
|
||||||
send({
|
send({
|
||||||
type: 'codex_app_user_input_response',
|
type: 'codex_app_user_input_response',
|
||||||
|
action: 'submit',
|
||||||
sessionId: msg.sessionId,
|
sessionId: msg.sessionId,
|
||||||
requestId: msg.requestId,
|
requestId: msg.requestId,
|
||||||
answers: collectCodexAppUserInputAnswers(panel, questions),
|
answers: collectCodexAppUserInputAnswers(panel, questions),
|
||||||
@@ -2186,6 +2220,55 @@
|
|||||||
return { pinnedSessions, regularSessions };
|
return { pinnedSessions, regularSessions };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isOlderThanOldSessionWindow(session, nowMs = Date.now()) {
|
||||||
|
const updatedMs = new Date(session?.updated || 0).getTime();
|
||||||
|
return Number.isFinite(updatedMs) && updatedMs > 0 && nowMs - updatedMs > OLD_SESSION_COLLAPSE_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function splitCollapsedOldSessions(regularSessions, pinnedCount) {
|
||||||
|
if (expandedOldSessionAgents.has(currentAgent)) {
|
||||||
|
return { visibleRegularSessions: regularSessions, hiddenOldSessions: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalCount = pinnedCount + regularSessions.length;
|
||||||
|
if (totalCount <= OLD_SESSION_COLLAPSE_VISIBLE_LIMIT) {
|
||||||
|
return { visibleRegularSessions: regularSessions, hiddenOldSessions: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const nowMs = Date.now();
|
||||||
|
const visibleRegularLimit = Math.max(0, OLD_SESSION_COLLAPSE_VISIBLE_LIMIT - pinnedCount);
|
||||||
|
const visibleRegularSessions = [];
|
||||||
|
const hiddenOldSessions = [];
|
||||||
|
|
||||||
|
regularSessions.forEach((session, index) => {
|
||||||
|
const shouldKeepVisible = session.id === currentSessionId || session.isRunning || session.hasUnread;
|
||||||
|
const canCollapse = index >= visibleRegularLimit && isOlderThanOldSessionWindow(session, nowMs);
|
||||||
|
if (canCollapse && !shouldKeepVisible) {
|
||||||
|
hiddenOldSessions.push(session);
|
||||||
|
} else {
|
||||||
|
visibleRegularSessions.push(session);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { visibleRegularSessions, hiddenOldSessions };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOldSessionLoadMoreButton(hiddenCount) {
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.type = 'button';
|
||||||
|
button.className = 'session-list-load-more';
|
||||||
|
button.setAttribute('aria-label', `加载更多 ${hiddenCount} 条 7 天前会话`);
|
||||||
|
button.innerHTML = `
|
||||||
|
<span class="session-list-load-more-title">加载更多</span>
|
||||||
|
<span class="session-list-load-more-meta">${hiddenCount} 条 7 天前会话</span>
|
||||||
|
`;
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
expandedOldSessionAgents.add(currentAgent);
|
||||||
|
renderSessionList();
|
||||||
|
});
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
function applySessionPinnedState(sessionId, pinnedAt) {
|
function applySessionPinnedState(sessionId, pinnedAt) {
|
||||||
sessions = sessions.map((session) => (
|
sessions = sessions.map((session) => (
|
||||||
session.id === sessionId ? { ...session, pinnedAt: pinnedAt || null } : session
|
session.id === sessionId ? { ...session, pinnedAt: pinnedAt || null } : session
|
||||||
@@ -2221,25 +2304,36 @@
|
|||||||
<span class="session-item-time">${timeAgo(session.updated)}</span>
|
<span class="session-item-time">${timeAgo(session.updated)}</span>
|
||||||
<div class="session-item-actions">
|
<div class="session-item-actions">
|
||||||
<button class="session-item-btn pin${isPinned ? ' active' : ''}" title="${isPinned ? '取消置顶' : '置顶'}" aria-label="${isPinned ? '取消置顶' : '置顶'}">${isPinned ? '✓' : '⇧'}</button>
|
<button class="session-item-btn pin${isPinned ? ' active' : ''}" title="${isPinned ? '取消置顶' : '置顶'}" aria-label="${isPinned ? '取消置顶' : '置顶'}">${isPinned ? '✓' : '⇧'}</button>
|
||||||
<button class="session-item-btn copy-id" title="复制 ID">ID</button>
|
<div class="session-item-more">
|
||||||
<button class="session-item-btn edit" title="重命名">✎</button>
|
<button class="session-item-btn more" type="button" title="更多操作" aria-label="更多操作" aria-haspopup="menu">⋯</button>
|
||||||
<button class="session-item-btn delete" title="删除">×</button>
|
<div class="session-item-menu" role="menu" aria-label="会话操作">
|
||||||
|
<button class="session-item-menu-btn copy-id" type="button" role="menuitem">复制 ID</button>
|
||||||
|
<button class="session-item-menu-btn edit" type="button" role="menuitem">重命名</button>
|
||||||
|
<button class="session-item-menu-btn delete" type="button" role="menuitem">删除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
item.addEventListener('click', (e) => {
|
item.addEventListener('click', (e) => {
|
||||||
const target = e.target;
|
const target = e.target instanceof Element
|
||||||
if (target.classList.contains('copy-id')) {
|
? e.target.closest('.session-item-btn, .session-item-menu-btn')
|
||||||
|
: null;
|
||||||
|
if (target?.classList.contains('more')) {
|
||||||
|
e.stopPropagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (target?.classList.contains('copy-id')) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
copyTextToClipboard(session.id, '会话 ID 已复制');
|
copyTextToClipboard(session.id, '会话 ID 已复制');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (target.classList.contains('pin')) {
|
if (target?.classList.contains('pin')) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
toggleSessionPinned(session);
|
toggleSessionPinned(session);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (target.classList.contains('delete')) {
|
if (target?.classList.contains('delete')) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const doDelete = () => {
|
const doDelete = () => {
|
||||||
if (getLastSessionForAgent(currentAgent) === session.id) {
|
if (getLastSessionForAgent(currentAgent) === session.id) {
|
||||||
@@ -2259,11 +2353,15 @@
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (target.classList.contains('edit')) {
|
if (target?.classList.contains('edit')) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
startEditSessionTitle(item, session);
|
startEditSessionTitle(item, session);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (e.target instanceof Element && e.target.closest('.session-item-more')) {
|
||||||
|
e.stopPropagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
openSession(session.id);
|
openSession(session.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2318,6 +2416,7 @@
|
|||||||
importSessionBtn.disabled = false;
|
importSessionBtn.disabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
updateReloadMcpButtonUI();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setCurrentAgent(agent) {
|
function setCurrentAgent(agent) {
|
||||||
@@ -2364,6 +2463,7 @@
|
|||||||
chatTitle.textContent = '新会话';
|
chatTitle.textContent = '新会话';
|
||||||
updateSessionIdBadge();
|
updateSessionIdBadge();
|
||||||
updateCwdBadge();
|
updateCwdBadge();
|
||||||
|
updateReloadMcpButtonUI();
|
||||||
messagesDiv.innerHTML = buildWelcomeMarkup(currentAgent);
|
messagesDiv.innerHTML = buildWelcomeMarkup(currentAgent);
|
||||||
setStatsDisplay(null);
|
setStatsDisplay(null);
|
||||||
renderPendingAttachments();
|
renderPendingAttachments();
|
||||||
@@ -3286,7 +3386,23 @@
|
|||||||
if (role === 'system') {
|
if (role === 'system') {
|
||||||
const bubble = document.createElement('div');
|
const bubble = document.createElement('div');
|
||||||
bubble.className = 'msg-bubble';
|
bubble.className = 'msg-bubble';
|
||||||
bubble.textContent = content;
|
const text = document.createElement('span');
|
||||||
|
text.className = 'system-message-text';
|
||||||
|
text.textContent = content;
|
||||||
|
bubble.appendChild(text);
|
||||||
|
|
||||||
|
const closeBtn = document.createElement('button');
|
||||||
|
closeBtn.type = 'button';
|
||||||
|
closeBtn.className = 'system-message-close';
|
||||||
|
closeBtn.title = '关闭提示';
|
||||||
|
closeBtn.setAttribute('aria-label', '关闭提示');
|
||||||
|
closeBtn.textContent = '×';
|
||||||
|
closeBtn.addEventListener('click', (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
div.remove();
|
||||||
|
updateScrollbar();
|
||||||
|
});
|
||||||
|
bubble.appendChild(closeBtn);
|
||||||
div.appendChild(bubble);
|
div.appendChild(bubble);
|
||||||
return div;
|
return div;
|
||||||
}
|
}
|
||||||
@@ -4564,6 +4680,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { pinnedSessions, regularSessions } = splitPinnedSessions(visibleSessions);
|
const { pinnedSessions, regularSessions } = splitPinnedSessions(visibleSessions);
|
||||||
|
const { visibleRegularSessions, hiddenOldSessions } = splitCollapsedOldSessions(regularSessions, pinnedSessions.length);
|
||||||
if (pinnedSessions.length > 0) {
|
if (pinnedSessions.length > 0) {
|
||||||
const pinnedGroupEl = document.createElement('section');
|
const pinnedGroupEl = document.createElement('section');
|
||||||
pinnedGroupEl.className = 'session-project-group session-pinned-group';
|
pinnedGroupEl.className = 'session-project-group session-pinned-group';
|
||||||
@@ -4582,7 +4699,7 @@
|
|||||||
sessionList.appendChild(pinnedGroupEl);
|
sessionList.appendChild(pinnedGroupEl);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { groups: projectGroups, ungroupedSessions } = groupSessionsByProject(regularSessions);
|
const { groups: projectGroups, ungroupedSessions } = groupSessionsByProject(visibleRegularSessions);
|
||||||
for (const group of projectGroups) {
|
for (const group of projectGroups) {
|
||||||
const groupEl = document.createElement('section');
|
const groupEl = document.createElement('section');
|
||||||
groupEl.className = 'session-project-group';
|
groupEl.className = 'session-project-group';
|
||||||
@@ -4614,6 +4731,10 @@
|
|||||||
for (const s of ungroupedSessions) {
|
for (const s of ungroupedSessions) {
|
||||||
sessionList.appendChild(createSessionListItem(s));
|
sessionList.appendChild(createSessionListItem(s));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hiddenOldSessions.length > 0) {
|
||||||
|
sessionList.appendChild(createOldSessionLoadMoreButton(hiddenOldSessions.length));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startEditSessionTitle(itemEl, session) {
|
function startEditSessionTitle(itemEl, session) {
|
||||||
@@ -5253,6 +5374,13 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (reloadMcpBtn) {
|
||||||
|
reloadMcpBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
reloadCurrentMcpServers();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Split new-chat button
|
// Split new-chat button
|
||||||
newChatBtn.addEventListener('click', () => showNewSessionModal());
|
newChatBtn.addEventListener('click', () => showNewSessionModal());
|
||||||
newChatArrow.addEventListener('click', (e) => {
|
newChatArrow.addEventListener('click', (e) => {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
document.documentElement.dataset.dividerTime = dividerTime;
|
document.documentElement.dataset.dividerTime = dividerTime;
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<link rel="stylesheet" href="style.css?v=20260614-divider-time-selectfix">
|
<link rel="stylesheet" href="style.css?v=20260615-reload-mcp">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css">
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-dark.min.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -102,6 +102,7 @@
|
|||||||
<button id="user-outline-btn" class="user-outline-btn" type="button" aria-expanded="false" aria-controls="user-outline-panel" title="定位用户消息">定位</button>
|
<button id="user-outline-btn" class="user-outline-btn" type="button" aria-expanded="false" aria-controls="user-outline-panel" title="定位用户消息">定位</button>
|
||||||
<div id="user-outline-panel" class="user-outline-panel" hidden></div>
|
<div id="user-outline-panel" class="user-outline-panel" hidden></div>
|
||||||
</div>
|
</div>
|
||||||
|
<button id="reload-mcp-btn" class="reload-mcp-btn" type="button" title="重载 Codex App MCP 配置" hidden>重载 MCP</button>
|
||||||
<span id="cost-display" class="cost-display" hidden></span>
|
<span id="cost-display" class="cost-display" hidden></span>
|
||||||
</div>
|
</div>
|
||||||
<div id="attachment-tray" class="attachment-tray" hidden></div>
|
<div id="attachment-tray" class="attachment-tray" hidden></div>
|
||||||
@@ -149,6 +150,6 @@
|
|||||||
|
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.1/marked.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/12.0.1/marked.min.js"></script>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
||||||
<script src="app.js?v=20260614-divider-time-selectfix"></script>
|
<script src="app.js?v=20260615-reload-mcp"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
151
public/style.css
151
public/style.css
@@ -1194,32 +1194,39 @@ body.session-loading-active {
|
|||||||
}
|
}
|
||||||
.session-item-actions {
|
.session-item-actions {
|
||||||
display: none;
|
display: none;
|
||||||
|
align-items: center;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
|
position: relative;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.session-item:hover .session-item-actions { display: flex; }
|
.session-item:hover .session-item-actions,
|
||||||
|
.session-item:focus-within .session-item-actions {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
.session-item-btn {
|
.session-item-btn {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 2px 5px;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
.session-item-btn:hover { color: var(--text-primary); background: var(--bg-tertiary); }
|
.session-item-btn:hover { color: var(--text-primary); background: var(--bg-tertiary); }
|
||||||
.session-item-btn.copy-id {
|
|
||||||
min-width: 24px;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: 0.02em;
|
|
||||||
}
|
|
||||||
.session-item-btn.pin {
|
.session-item-btn.pin {
|
||||||
min-width: 24px;
|
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
.session-item-btn.more {
|
||||||
|
font-size: 16px;
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
.session-item-btn.pin.active {
|
.session-item-btn.pin.active {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
background: rgba(192, 85, 58, 0.1);
|
background: rgba(192, 85, 58, 0.1);
|
||||||
@@ -1228,7 +1235,88 @@ body.session-loading-active {
|
|||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
background: rgba(192, 85, 58, 0.12);
|
background: rgba(192, 85, 58, 0.12);
|
||||||
}
|
}
|
||||||
.session-item-btn.delete:hover { color: var(--danger); background: var(--accent-light); }
|
.session-item-more {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
.session-item-menu {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
z-index: 20;
|
||||||
|
display: none;
|
||||||
|
min-width: 104px;
|
||||||
|
padding: 5px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
box-shadow: 0 10px 24px rgba(61, 42, 26, 0.14);
|
||||||
|
}
|
||||||
|
.session-item-more:hover .session-item-menu,
|
||||||
|
.session-item-more:focus-within .session-item-menu {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.session-item-menu-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 7px 9px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.session-item-menu-btn:hover,
|
||||||
|
.session-item-menu-btn:focus-visible {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.session-item-menu-btn.delete:hover,
|
||||||
|
.session-item-menu-btn.delete:focus-visible {
|
||||||
|
background: var(--accent-light);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
.session-list-load-more {
|
||||||
|
width: calc(100% - 4px);
|
||||||
|
margin: 6px 2px 10px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 249, 242, 0.72);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.16s, border-color 0.16s, color 0.16s, transform 0.16s;
|
||||||
|
}
|
||||||
|
.session-list-load-more:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-color: rgba(192, 85, 58, 0.24);
|
||||||
|
color: var(--accent);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.session-list-load-more:focus-visible {
|
||||||
|
outline: 2px solid rgba(192, 85, 58, 0.22);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
.session-list-load-more-title {
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.session-list-load-more-meta {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
/* Inline edit in sidebar */
|
/* Inline edit in sidebar */
|
||||||
.session-item-edit-input {
|
.session-item-edit-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -1329,7 +1417,8 @@ body.session-loading-active {
|
|||||||
.chat-agent-btn:disabled,
|
.chat-agent-btn:disabled,
|
||||||
.chat-cwd:disabled,
|
.chat-cwd:disabled,
|
||||||
.mode-select:disabled,
|
.mode-select:disabled,
|
||||||
.user-outline-btn:disabled {
|
.user-outline-btn:disabled,
|
||||||
|
.reload-mcp-btn:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
@@ -1428,7 +1517,8 @@ body.session-loading-active {
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
.cost-display:empty { display: none; }
|
.cost-display:empty { display: none; }
|
||||||
.user-outline-btn {
|
.user-outline-btn,
|
||||||
|
.reload-mcp-btn {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
border: 1px solid rgba(91, 126, 161, 0.22);
|
border: 1px solid rgba(91, 126, 161, 0.22);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
@@ -1446,7 +1536,8 @@ body.session-loading-active {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.user-outline-btn:hover {
|
.user-outline-btn:hover,
|
||||||
|
.reload-mcp-btn:hover:not(:disabled) {
|
||||||
background: rgba(91, 126, 161, 0.16);
|
background: rgba(91, 126, 161, 0.16);
|
||||||
border-color: rgba(91, 126, 161, 0.34);
|
border-color: rgba(91, 126, 161, 0.34);
|
||||||
}
|
}
|
||||||
@@ -1987,9 +2078,37 @@ body.session-loading-active {
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
|
position: relative;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
white-space: pre-line;
|
white-space: pre-line;
|
||||||
}
|
}
|
||||||
|
.msg.system .system-message-text {
|
||||||
|
display: block;
|
||||||
|
padding-right: 26px;
|
||||||
|
}
|
||||||
|
.msg.system .system-message-close {
|
||||||
|
align-items: center;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 16px;
|
||||||
|
height: 24px;
|
||||||
|
justify-content: center;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0;
|
||||||
|
position: absolute;
|
||||||
|
right: 7px;
|
||||||
|
top: 7px;
|
||||||
|
width: 24px;
|
||||||
|
}
|
||||||
|
.msg.system .system-message-close:hover {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
/* Markdown content */
|
/* Markdown content */
|
||||||
.msg-bubble p { margin: 0 0 8px 0; }
|
.msg-bubble p { margin: 0 0 8px 0; }
|
||||||
@@ -2930,7 +3049,8 @@ html[data-divider-time='hide'] .msg-bubble .agent-message-divider span {
|
|||||||
padding: 4px 20px 4px 8px;
|
padding: 4px 20px 4px 8px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
.user-outline-btn {
|
.user-outline-btn,
|
||||||
|
.reload-mcp-btn {
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
@@ -2975,6 +3095,7 @@ html[data-divider-time='hide'] .msg-bubble .agent-message-divider span {
|
|||||||
.chat-cwd,
|
.chat-cwd,
|
||||||
.mode-select,
|
.mode-select,
|
||||||
.user-outline-btn,
|
.user-outline-btn,
|
||||||
|
.reload-mcp-btn,
|
||||||
.chat-runtime-state {
|
.chat-runtime-state {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
@@ -4548,10 +4669,12 @@ html[data-theme='coolvibe'] .settings-back:hover {
|
|||||||
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .session-list-empty,
|
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .session-list-empty,
|
||||||
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .session-project-header,
|
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .session-project-header,
|
||||||
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .session-project-create,
|
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .session-project-create,
|
||||||
|
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .session-list-load-more,
|
||||||
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .chat-agent-btn,
|
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .chat-agent-btn,
|
||||||
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .mode-select,
|
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .mode-select,
|
||||||
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .chat-cwd,
|
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .chat-cwd,
|
||||||
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .user-outline-btn,
|
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .user-outline-btn,
|
||||||
|
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .reload-mcp-btn,
|
||||||
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .user-outline-item,
|
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .user-outline-item,
|
||||||
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .settings-back,
|
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .settings-back,
|
||||||
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .settings-nav-card,
|
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .settings-nav-card,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ if (args[0] !== 'app-server') {
|
|||||||
const threads = new Map();
|
const threads = new Map();
|
||||||
const pendingServerRequests = new Map();
|
const pendingServerRequests = new Map();
|
||||||
let nextServerRequestId = 1;
|
let nextServerRequestId = 1;
|
||||||
|
let mcpReloadCount = 0;
|
||||||
|
|
||||||
function send(message) {
|
function send(message) {
|
||||||
process.stdout.write(`${JSON.stringify(message)}\n`);
|
process.stdout.write(`${JSON.stringify(message)}\n`);
|
||||||
@@ -429,6 +430,20 @@ function startTurn(params) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (/runtime warning/i.test(text)) {
|
||||||
|
const message = 'Heads up: Long threads and multiple compactions can cause the model to be less accurate. Start a new thread when possible to keep threads small and targeted.';
|
||||||
|
for (let i = 0; i < 2; i += 1) {
|
||||||
|
send({
|
||||||
|
method: 'warning',
|
||||||
|
params: {
|
||||||
|
threadId: thread.id,
|
||||||
|
turnId,
|
||||||
|
message,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (/collaboration/i.test(text)) {
|
if (/collaboration/i.test(text)) {
|
||||||
completeTurn(thread, turnId, `collaboration mode: ${collaborationSummary(params)}`);
|
completeTurn(thread, turnId, `collaboration mode: ${collaborationSummary(params)}`);
|
||||||
return { turn: { id: turnId, status: 'running', items: [] } };
|
return { turn: { id: turnId, status: 'running', items: [] } };
|
||||||
@@ -516,6 +531,11 @@ function handleRequest(message) {
|
|||||||
send({ id, result: { data: [{ mode: 'default' }, { mode: 'plan' }] } });
|
send({ id, result: { data: [{ mode: 'default' }, { mode: 'plan' }] } });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (method === 'config/mcpServer/reload') {
|
||||||
|
mcpReloadCount += 1;
|
||||||
|
send({ id, result: { reloaded: true, reloadCount: mcpReloadCount } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (method === 'thread/start') {
|
if (method === 'thread/start') {
|
||||||
const thread = ensureThread(null, params);
|
const thread = ensureThread(null, params);
|
||||||
send({ id, result: { thread: threadPayload(thread), model: params.model || 'gpt-5.5', cwd: thread.cwd, modelProvider: 'mock', approvalPolicy: params.approvalPolicy || 'never', approvalsReviewer: 'user', sandbox: params.sandbox || 'danger-full-access' } });
|
send({ id, result: { thread: threadPayload(thread), model: params.model || 'gpt-5.5', cwd: thread.cwd, modelProvider: 'mock', approvalPolicy: params.approvalPolicy || 'never', approvalsReviewer: 'user', sandbox: params.sandbox || 'danger-full-access' } });
|
||||||
|
|||||||
@@ -168,6 +168,20 @@ async function fetchAuthedJson(port, token, pathname) {
|
|||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function postAuthedJson(port, token, pathname, body = {}) {
|
||||||
|
const response = await fetch(`http://127.0.0.1:${port}${pathname}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const payload = await response.json();
|
||||||
|
assert(response.ok && payload.ok, `POST failed for ${pathname}: ${payload.message || response.status}`);
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
async function callInternalMcp(port, token, payload) {
|
async function callInternalMcp(port, token, payload) {
|
||||||
const response = await fetch(`http://127.0.0.1:${port}/api/internal/mcp`, {
|
const response = await fetch(`http://127.0.0.1:${port}/api/internal/mcp`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -785,6 +799,22 @@ async function main() {
|
|||||||
assert(/"hasTopLevelEffort":false/.test(codexAppDefaultCollab.text || ''), 'Codex App collaboration turn should not duplicate effort at top level');
|
assert(/"hasTopLevelEffort":false/.test(codexAppDefaultCollab.text || ''), 'Codex App collaboration turn should not duplicate effort at top level');
|
||||||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
|
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
|
||||||
|
|
||||||
|
ws.send(JSON.stringify({ type: 'message', text: 'codexapp runtime warning prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
||||||
|
const codexAppRuntimeWarning = await nextMessage(messages, ws, (msg) => (
|
||||||
|
msg.type === 'system_message' &&
|
||||||
|
msg.sessionId === codexAppSession.sessionId &&
|
||||||
|
/Long threads and multiple compactions/.test(msg.message || '')
|
||||||
|
));
|
||||||
|
assert(/Long threads and multiple compactions/.test(codexAppRuntimeWarning.message || ''), 'Codex App should surface the first runtime warning');
|
||||||
|
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
|
||||||
|
await sleep(150);
|
||||||
|
const duplicateRuntimeWarnings = messages.filter((msg) => (
|
||||||
|
msg.type === 'system_message' &&
|
||||||
|
msg.sessionId === codexAppSession.sessionId &&
|
||||||
|
/Long threads and multiple compactions/.test(msg.message || '')
|
||||||
|
));
|
||||||
|
assert(duplicateRuntimeWarnings.length === 0, 'Codex App should suppress duplicate runtime warning banners');
|
||||||
|
|
||||||
ws.send(JSON.stringify({ type: 'message', text: 'codexapp empty reasoning prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
ws.send(JSON.stringify({ type: 'message', text: 'codexapp empty reasoning prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
||||||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
|
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
|
||||||
const storedCodexAppAfterReasoning = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
|
const storedCodexAppAfterReasoning = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${codexAppSession.sessionId}.json`), 'utf8'));
|
||||||
@@ -804,6 +834,10 @@ async function main() {
|
|||||||
assert(storedCodexApp.messages.some((message) => message.role === 'assistant' && /codexapp tool prompt/.test(String(message.content || ''))), 'Codex App assistant response should be persisted');
|
assert(storedCodexApp.messages.some((message) => message.role === 'assistant' && /codexapp tool prompt/.test(String(message.content || ''))), 'Codex App assistant response should be persisted');
|
||||||
assert((storedCodexApp.totalUsage?.inputTokens || 0) > 0, 'Codex App token usage should be persisted');
|
assert((storedCodexApp.totalUsage?.inputTokens || 0) > 0, 'Codex App token usage should be persisted');
|
||||||
|
|
||||||
|
const reloadMcpResult = await postAuthedJson(port, token, `/api/sessions/${codexAppSession.sessionId}/reload-mcp`);
|
||||||
|
assert(reloadMcpResult.sessionId === codexAppSession.sessionId, 'Codex App MCP reload should return the target session id');
|
||||||
|
assert(reloadMcpResult.result?.reloaded === true, 'Codex App MCP reload should call app-server config/mcpServer/reload');
|
||||||
|
|
||||||
ws.send(JSON.stringify({ type: 'message', text: 'codexapp dynamic prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
ws.send(JSON.stringify({ type: 'message', text: 'codexapp dynamic prompt', sessionId: codexAppSession.sessionId, mode: 'yolo', agent: 'codexapp' }));
|
||||||
const codexAppDynamicTool = await nextMessage(messages, ws, (msg) => msg.type === 'tool_end' && msg.sessionId === codexAppSession.sessionId && msg.toolUseId === 'mcp-ccweb-list');
|
const codexAppDynamicTool = await nextMessage(messages, ws, (msg) => msg.type === 'tool_end' && msg.sessionId === codexAppSession.sessionId && msg.toolUseId === 'mcp-ccweb-list');
|
||||||
assert(codexAppDynamicTool.kind === 'mcp_tool_call', 'Codex App should surface ccweb MCP tool calls');
|
assert(codexAppDynamicTool.kind === 'mcp_tool_call', 'Codex App should surface ccweb MCP tool calls');
|
||||||
@@ -835,10 +869,17 @@ async function main() {
|
|||||||
assert(guidedRequest.questions?.[0]?.id === 'choice', 'Codex App should forward request_user_input questions');
|
assert(guidedRequest.questions?.[0]?.id === 'choice', 'Codex App should forward request_user_input questions');
|
||||||
ws.send(JSON.stringify({
|
ws.send(JSON.stringify({
|
||||||
type: 'codex_app_user_input_response',
|
type: 'codex_app_user_input_response',
|
||||||
|
action: 'submit',
|
||||||
sessionId: codexAppSession.sessionId,
|
sessionId: codexAppSession.sessionId,
|
||||||
requestId: guidedRequest.requestId,
|
requestId: guidedRequest.requestId,
|
||||||
answers: { choice: { answers: ['A'] } },
|
answers: { choice: { answers: ['A'] } },
|
||||||
}));
|
}));
|
||||||
|
const guidedSubmitted = await nextMessage(messages, ws, (msg) =>
|
||||||
|
msg.type === 'system_message' &&
|
||||||
|
msg.sessionId === codexAppSession.sessionId &&
|
||||||
|
/已提交.*引导输入/.test(msg.message || '')
|
||||||
|
);
|
||||||
|
assert(/已提交.*引导输入/.test(guidedSubmitted.message || ''), 'Codex App should show guided input submission hint');
|
||||||
const guidedDelta = await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && msg.sessionId === codexAppSession.sessionId && /guided answer: A/.test(msg.text || ''));
|
const guidedDelta = await nextMessage(messages, ws, (msg) => msg.type === 'text_delta' && msg.sessionId === codexAppSession.sessionId && /guided answer: A/.test(msg.text || ''));
|
||||||
assert(/guided answer: A/.test(guidedDelta.text || ''), 'Codex App should continue after guided input response');
|
assert(/guided answer: A/.test(guidedDelta.text || ''), 'Codex App should continue after guided input response');
|
||||||
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
|
await nextMessage(messages, ws, (msg) => msg.type === 'done' && msg.sessionId === codexAppSession.sessionId);
|
||||||
|
|||||||
79
server.js
79
server.js
@@ -2015,6 +2015,69 @@ function getRuntimeSessionId(session) {
|
|||||||
return session.claudeSessionId || null;
|
return session.claudeSessionId || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleReloadMcpApi(req, res, rawSessionId) {
|
||||||
|
const token = extractBearerToken(req);
|
||||||
|
if (!token || !activeTokens.has(token)) {
|
||||||
|
return jsonResponse(res, 401, { ok: false, message: 'Not authenticated' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionId = sanitizeId(rawSessionId || '');
|
||||||
|
if (!sessionId) {
|
||||||
|
return jsonResponse(res, 400, { ok: false, code: 'missing_session_id', message: '缺少会话 ID' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = loadSession(sessionId);
|
||||||
|
if (!session) {
|
||||||
|
return jsonResponse(res, 404, { ok: false, code: 'session_not_found', message: '会话不存在' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isCodexAppSession(session)) {
|
||||||
|
return jsonResponse(res, 400, {
|
||||||
|
ok: false,
|
||||||
|
code: 'reload_mcp_unsupported_agent',
|
||||||
|
message: '重载 MCP 仅支持 Codex App 会话。旧 Codex 会话请重启本地 Codex 后再继续。',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const clientResult = getCodexAppClient();
|
||||||
|
if (clientResult.error) {
|
||||||
|
return jsonResponse(res, 500, {
|
||||||
|
ok: false,
|
||||||
|
code: 'codexapp_client_unavailable',
|
||||||
|
message: clientResult.error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = clientResult.client;
|
||||||
|
await client.start();
|
||||||
|
const result = typeof client.reloadMcpServers === 'function'
|
||||||
|
? await client.reloadMcpServers()
|
||||||
|
: await client.request('config/mcpServer/reload', {}, 30000);
|
||||||
|
|
||||||
|
plog('INFO', 'codex_app_mcp_reload_requested', {
|
||||||
|
sessionId: sessionId.slice(0, 8),
|
||||||
|
threadId: getRuntimeSessionId(session) || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return jsonResponse(res, 200, {
|
||||||
|
ok: true,
|
||||||
|
sessionId,
|
||||||
|
threadId: getRuntimeSessionId(session) || null,
|
||||||
|
result: result || {},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const unsupported = err?.code === -32601 || /not found|unknown|unsupported|method/i.test(String(err?.message || ''));
|
||||||
|
return jsonResponse(res, unsupported ? 501 : 500, {
|
||||||
|
ok: false,
|
||||||
|
code: unsupported ? 'codexapp_reload_mcp_unsupported' : 'codexapp_reload_mcp_failed',
|
||||||
|
message: unsupported
|
||||||
|
? `当前 Codex app-server 不支持重载 MCP,请重启 Codex App。${err?.message ? `(${err.message})` : ''}`
|
||||||
|
: `重载 MCP 失败: ${err?.message || err}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function setRuntimeSessionId(session, runtimeId) {
|
function setRuntimeSessionId(session, runtimeId) {
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
const agent = getSessionAgent(session);
|
const agent = getSessionAgent(session);
|
||||||
@@ -2900,6 +2963,11 @@ const server = http.createServer((req, res) => {
|
|||||||
return handleInternalMcpApi(req, res);
|
return handleInternalMcpApi(req, res);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reloadMcpMatch = url.pathname.match(/^\/api\/sessions\/([^/]+)\/reload-mcp$/);
|
||||||
|
if (req.method === 'POST' && reloadMcpMatch) {
|
||||||
|
return handleReloadMcpApi(req, res, decodeURIComponent(reloadMcpMatch[1] || ''));
|
||||||
|
}
|
||||||
|
|
||||||
if (req.method === 'POST' && url.pathname === '/api/attachments') {
|
if (req.method === 'POST' && url.pathname === '/api/attachments') {
|
||||||
const token = extractBearerToken(req);
|
const token = extractBearerToken(req);
|
||||||
if (!token || !activeTokens.has(token)) {
|
if (!token || !activeTokens.has(token)) {
|
||||||
@@ -4554,7 +4622,16 @@ function handleCodexAppUserInputResponse(ws, msg = {}) {
|
|||||||
|
|
||||||
pendingCodexAppUserInputs.delete(requestId);
|
pendingCodexAppUserInputs.delete(requestId);
|
||||||
clearTimeout(pending.timer);
|
clearTimeout(pending.timer);
|
||||||
pending.resolve(normalizeCodexAppUserInputAnswers(msg.answers || {}));
|
const action = String(msg.action || 'submit').trim();
|
||||||
|
const isCancel = action === 'cancel';
|
||||||
|
if (!isCancel) {
|
||||||
|
wsSend(ws, {
|
||||||
|
type: 'system_message',
|
||||||
|
sessionId: pending.sessionId,
|
||||||
|
message: '已提交 Codex App 引导输入。',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
pending.resolve(isCancel ? { answers: {} } : normalizeCodexAppUserInputAnswers(msg.answers || {}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolvePendingCodexAppUserInputsForSession(sessionId) {
|
function resolvePendingCodexAppUserInputsForSession(sessionId) {
|
||||||
|
|||||||
Reference in New Issue
Block a user