chore: rebuild CentOS7 release package
This commit is contained in:
Binary file not shown.
@@ -21,6 +21,7 @@ function createAgentRuntime(deps) {
|
|||||||
saveSession,
|
saveSession,
|
||||||
setRuntimeSessionId,
|
setRuntimeSessionId,
|
||||||
getRuntimeSessionId,
|
getRuntimeSessionId,
|
||||||
|
titleToolInstructions,
|
||||||
} = deps;
|
} = deps;
|
||||||
|
|
||||||
function readRuntimePositiveIntEnv(name, fallback, options = {}) {
|
function readRuntimePositiveIntEnv(name, fallback, options = {}) {
|
||||||
@@ -179,6 +180,9 @@ function createAgentRuntime(deps) {
|
|||||||
const ccwebMcpEnv = createCcwebMcpEnv(session, options);
|
const ccwebMcpEnv = createCcwebMcpEnv(session, options);
|
||||||
appendCcwebMcpConfig(args, ccwebMcpEnv);
|
appendCcwebMcpConfig(args, ccwebMcpEnv);
|
||||||
appendProjectMcpConfigs(args, options.projectMcpConfigs);
|
appendProjectMcpConfigs(args, options.projectMcpConfigs);
|
||||||
|
if (titleToolInstructions) {
|
||||||
|
args.push('-c', `developer_instructions=${tomlString(titleToolInstructions)}`);
|
||||||
|
}
|
||||||
|
|
||||||
const permMode = session.permissionMode || 'yolo';
|
const permMode = session.permissionMode || 'yolo';
|
||||||
// `-s/--sandbox` is an option for `codex exec`, but not for `codex exec resume`.
|
// `-s/--sandbox` is an option for `codex exec`, but not for `codex exec resume`.
|
||||||
|
|||||||
@@ -36,6 +36,22 @@ const TOOLS = [
|
|||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'ccweb_set_title',
|
||||||
|
description: '设置当前 ccweb 对话标题。只作用于当前来源对话;用户手动重命名后会成功返回但忽略,不再覆盖用户标题。',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
title: {
|
||||||
|
type: 'string',
|
||||||
|
maxLength: 120,
|
||||||
|
description: '新的当前对话标题。应简短概括用户主要目标。',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['title'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'ccweb_create_conversation',
|
name: 'ccweb_create_conversation',
|
||||||
description: '创建一个新的 ccweb 持久对话。Agent 固定继承来源对话,不作为参数指定;只用于需要在会话列表中长期追踪、后续可继续对话的工作流;一次性并行研究应优先使用子代能力。',
|
description: '创建一个新的 ccweb 持久对话。Agent 固定继承来源对话,不作为参数指定;只用于需要在会话列表中长期追踪、后续可继续对话的工作流;一次性并行研究应优先使用子代能力。',
|
||||||
|
|||||||
@@ -78,6 +78,7 @@
|
|||||||
const SESSION_LOAD_OVERLAY_TIMEOUT_MS = 12_000;
|
const SESSION_LOAD_OVERLAY_TIMEOUT_MS = 12_000;
|
||||||
const SESSION_LOAD_REQUEST_TIMEOUT_MS = 45_000;
|
const SESSION_LOAD_REQUEST_TIMEOUT_MS = 45_000;
|
||||||
const SESSION_RESUME_FALLBACK_MS = 1_500;
|
const SESSION_RESUME_FALLBACK_MS = 1_500;
|
||||||
|
const PENDING_SLASH_DRAFT_LIMIT = 30;
|
||||||
|
|
||||||
const MODEL_OPTIONS = [
|
const MODEL_OPTIONS = [
|
||||||
{ value: 'opus', label: 'Opus', desc: '最强大,1M 上下文' },
|
{ value: 'opus', label: 'Opus', desc: '最强大,1M 上下文' },
|
||||||
@@ -237,6 +238,7 @@
|
|||||||
const pendingNotesByTarget = new Map();
|
const pendingNotesByTarget = new Map();
|
||||||
const queuedMessagesByTarget = new Map();
|
const queuedMessagesByTarget = new Map();
|
||||||
const userMessageIndex = new Map();
|
const userMessageIndex = new Map();
|
||||||
|
const pendingSlashDraftsByRequestId = new Map();
|
||||||
const expandedOldSessionGroups = new Set();
|
const expandedOldSessionGroups = new Set();
|
||||||
document.documentElement.dataset.dividerTime = showAgentDividerTime ? 'show' : 'hide';
|
document.documentElement.dataset.dividerTime = showAgentDividerTime ? 'show' : 'hide';
|
||||||
|
|
||||||
@@ -1334,6 +1336,37 @@
|
|||||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rememberPendingSlashDraft(requestId, text, sessionId, agent) {
|
||||||
|
const id = String(requestId || '').trim();
|
||||||
|
if (!id) return;
|
||||||
|
while (pendingSlashDraftsByRequestId.size >= PENDING_SLASH_DRAFT_LIMIT) {
|
||||||
|
const oldest = pendingSlashDraftsByRequestId.keys().next().value;
|
||||||
|
if (!oldest) break;
|
||||||
|
pendingSlashDraftsByRequestId.delete(oldest);
|
||||||
|
}
|
||||||
|
pendingSlashDraftsByRequestId.set(id, {
|
||||||
|
text,
|
||||||
|
sessionId: sessionId || null,
|
||||||
|
agent: normalizeAgent(agent),
|
||||||
|
createdAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPendingSlashDraftResponse(msg) {
|
||||||
|
const requestId = String(msg?.requestId || '').trim();
|
||||||
|
if (!requestId) return false;
|
||||||
|
const draft = pendingSlashDraftsByRequestId.get(requestId);
|
||||||
|
if (!draft) return false;
|
||||||
|
pendingSlashDraftsByRequestId.delete(requestId);
|
||||||
|
if (!msg.preserveComposerDraft) return false;
|
||||||
|
if (draft.sessionId && currentSessionId && draft.sessionId !== currentSessionId) return false;
|
||||||
|
if (normalizeAgent(draft.agent) !== normalizeAgent(currentAgent)) return false;
|
||||||
|
if (msgInput.value !== '' || pendingAttachments.length > 0) return false;
|
||||||
|
msgInput.value = draft.text;
|
||||||
|
autoResize();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
function clearUserMessageIndex() {
|
function clearUserMessageIndex() {
|
||||||
userMessageIndex.clear();
|
userMessageIndex.clear();
|
||||||
}
|
}
|
||||||
@@ -1756,6 +1789,8 @@
|
|||||||
model: payload.model || '',
|
model: payload.model || '',
|
||||||
agent: normalizeAgent(payload.agent),
|
agent: normalizeAgent(payload.agent),
|
||||||
pinnedAt: payload.pinnedAt || null,
|
pinnedAt: payload.pinnedAt || null,
|
||||||
|
titleSource: payload.titleSource || null,
|
||||||
|
createdFromKind: payload.createdFromKind || null,
|
||||||
hasUnread: !!payload.hasUnread,
|
hasUnread: !!payload.hasUnread,
|
||||||
cwd: payload.cwd || null,
|
cwd: payload.cwd || null,
|
||||||
projectName: payload.projectName || '',
|
projectName: payload.projectName || '',
|
||||||
@@ -1860,6 +1895,8 @@
|
|||||||
agent: normalizeAgent(snapshot.agent),
|
agent: normalizeAgent(snapshot.agent),
|
||||||
updated: snapshot.updated || new Date().toISOString(),
|
updated: snapshot.updated || new Date().toISOString(),
|
||||||
pinnedAt: snapshot.pinnedAt || null,
|
pinnedAt: snapshot.pinnedAt || null,
|
||||||
|
titleSource: snapshot.titleSource || null,
|
||||||
|
createdFromKind: snapshot.createdFromKind || null,
|
||||||
hasUnread: !!snapshot.hasUnread,
|
hasUnread: !!snapshot.hasUnread,
|
||||||
isRunning: !!snapshot.isRunning,
|
isRunning: !!snapshot.isRunning,
|
||||||
waitingOnChildren: !!snapshot.waitingOnChildren,
|
waitingOnChildren: !!snapshot.waitingOnChildren,
|
||||||
@@ -1880,6 +1917,8 @@
|
|||||||
cwd: nextMeta.cwd || session.cwd || '',
|
cwd: nextMeta.cwd || session.cwd || '',
|
||||||
projectName: nextMeta.cwd ? getPathLeaf(nextMeta.cwd) : session.projectName || nextMeta.projectName || '',
|
projectName: nextMeta.cwd ? getPathLeaf(nextMeta.cwd) : session.projectName || nextMeta.projectName || '',
|
||||||
title: nextMeta.title || session.title,
|
title: nextMeta.title || session.title,
|
||||||
|
titleSource: nextMeta.titleSource || session.titleSource || null,
|
||||||
|
createdFromKind: nextMeta.createdFromKind || session.createdFromKind || null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
if (!found) {
|
if (!found) {
|
||||||
@@ -2496,12 +2535,25 @@
|
|||||||
function updateCcwebPromptAnswerFromSelection(questionEl, question) {
|
function updateCcwebPromptAnswerFromSelection(questionEl, question) {
|
||||||
const textarea = questionEl.querySelector('.ccweb-prompt-answer');
|
const textarea = questionEl.querySelector('.ccweb-prompt-answer');
|
||||||
if (!textarea) return;
|
if (!textarea) return;
|
||||||
|
const previousSelectionText = textarea.dataset.ccwebPromptSelectionText || '';
|
||||||
const selectedIds = Array.from(questionEl.querySelectorAll('.ccweb-prompt-option.is-selected'))
|
const selectedIds = Array.from(questionEl.querySelectorAll('.ccweb-prompt-option.is-selected'))
|
||||||
.map((button) => button.dataset.optionId || '')
|
.map((button) => button.dataset.optionId || '')
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
const selectedOptions = (question.options || []).filter((option) => selectedIds.includes(option.id));
|
const selectedOptions = (question.options || []).filter((option) => selectedIds.includes(option.id));
|
||||||
const answerText = selectedOptions.map((option) => option.answerText || option.label || '').filter(Boolean).join('\n');
|
const answerText = selectedOptions.map((option) => option.answerText || option.label || '').filter(Boolean).join('\n');
|
||||||
if (answerText) textarea.value = answerText;
|
if (answerText) {
|
||||||
|
textarea.value = answerText;
|
||||||
|
textarea.dataset.ccwebPromptSelectionText = answerText;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (previousSelectionText && textarea.value === previousSelectionText) textarea.value = '';
|
||||||
|
delete textarea.dataset.ccwebPromptSelectionText;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncCcwebPromptOptionState(questionEl) {
|
||||||
|
questionEl.querySelectorAll('.ccweb-prompt-option').forEach((button) => {
|
||||||
|
button.setAttribute('aria-pressed', button.classList.contains('is-selected') ? 'true' : 'false');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectCcwebPromptOption(questionEl, question, optionId) {
|
function selectCcwebPromptOption(questionEl, question, optionId) {
|
||||||
@@ -2511,10 +2563,20 @@
|
|||||||
if (button.dataset.optionId === optionId) button.classList.toggle('is-selected');
|
if (button.dataset.optionId === optionId) button.classList.toggle('is-selected');
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
const shouldDeselect = buttons.some((button) => button.dataset.optionId === optionId && button.classList.contains('is-selected'));
|
||||||
buttons.forEach((button) => {
|
buttons.forEach((button) => {
|
||||||
button.classList.toggle('is-selected', button.dataset.optionId === optionId);
|
button.classList.toggle('is-selected', !shouldDeselect && button.dataset.optionId === optionId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
syncCcwebPromptOptionState(questionEl);
|
||||||
|
updateCcwebPromptAnswerFromSelection(questionEl, question);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCcwebPromptSelection(questionEl, question) {
|
||||||
|
questionEl.querySelectorAll('.ccweb-prompt-option.is-selected').forEach((button) => {
|
||||||
|
button.classList.remove('is-selected');
|
||||||
|
});
|
||||||
|
syncCcwebPromptOptionState(questionEl);
|
||||||
updateCcwebPromptAnswerFromSelection(questionEl, question);
|
updateCcwebPromptAnswerFromSelection(questionEl, question);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2568,6 +2630,7 @@
|
|||||||
button.type = 'button';
|
button.type = 'button';
|
||||||
button.className = 'ccweb-prompt-option';
|
button.className = 'ccweb-prompt-option';
|
||||||
button.dataset.optionId = option.id || '';
|
button.dataset.optionId = option.id || '';
|
||||||
|
button.setAttribute('aria-pressed', 'false');
|
||||||
const label = document.createElement('span');
|
const label = document.createElement('span');
|
||||||
label.className = 'ccweb-prompt-option-label';
|
label.className = 'ccweb-prompt-option-label';
|
||||||
label.textContent = option.label || option.id || '选项';
|
label.textContent = option.label || option.id || '选项';
|
||||||
@@ -2587,6 +2650,12 @@
|
|||||||
button.addEventListener('click', () => selectCcwebPromptOption(questionEl, question, option.id));
|
button.addEventListener('click', () => selectCcwebPromptOption(questionEl, question, option.id));
|
||||||
optionList.appendChild(button);
|
optionList.appendChild(button);
|
||||||
});
|
});
|
||||||
|
const clearChoice = document.createElement('button');
|
||||||
|
clearChoice.type = 'button';
|
||||||
|
clearChoice.className = 'ccweb-prompt-clear-choice';
|
||||||
|
clearChoice.textContent = '不选择';
|
||||||
|
clearChoice.addEventListener('click', () => clearCcwebPromptSelection(questionEl, question));
|
||||||
|
optionList.appendChild(clearChoice);
|
||||||
questionEl.appendChild(optionList);
|
questionEl.appendChild(optionList);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2595,6 +2664,9 @@
|
|||||||
answer.rows = 4;
|
answer.rows = 4;
|
||||||
answer.placeholder = question.answerPlaceholder || '填写你的答案...';
|
answer.placeholder = question.answerPlaceholder || '填写你的答案...';
|
||||||
answer.value = question.defaultAnswer || '';
|
answer.value = question.defaultAnswer || '';
|
||||||
|
answer.addEventListener('input', () => {
|
||||||
|
delete answer.dataset.ccwebPromptSelectionText;
|
||||||
|
});
|
||||||
questionEl.appendChild(answer);
|
questionEl.appendChild(answer);
|
||||||
|
|
||||||
const recommended = ccwebPromptRecommendedOption(question);
|
const recommended = ccwebPromptRecommendedOption(question);
|
||||||
@@ -3904,10 +3976,11 @@
|
|||||||
function createSessionListItem(session) {
|
function createSessionListItem(session) {
|
||||||
const item = document.createElement('div');
|
const item = document.createElement('div');
|
||||||
const isPinned = !!session.pinnedAt;
|
const isPinned = !!session.pinnedAt;
|
||||||
|
const isLlmCreated = String(session.createdFromKind || '').toLowerCase() === 'mcp';
|
||||||
const waitingOnChildren = !!session.waitingOnChildren;
|
const waitingOnChildren = !!session.waitingOnChildren;
|
||||||
const readyReplyCount = Number(session.readyReplyCount || 0);
|
const readyReplyCount = Number(session.readyReplyCount || 0);
|
||||||
const waitingLabel = readyReplyCount > 0 ? `子对话已返回 ${readyReplyCount}` : `等待子对话 ${Number(session.pendingReplyCount || 0) || ''}`.trim();
|
const waitingLabel = readyReplyCount > 0 ? `子对话已返回 ${readyReplyCount}` : `等待子对话 ${Number(session.pendingReplyCount || 0) || ''}`.trim();
|
||||||
item.className = `session-item${session.id === currentSessionId ? ' active' : ''}${isPinned ? ' pinned' : ''}${waitingOnChildren ? ' waiting-children' : ''}`;
|
item.className = `session-item${session.id === currentSessionId ? ' active' : ''}${isPinned ? ' pinned' : ''}${isLlmCreated ? ' llm-created' : ''}${waitingOnChildren ? ' waiting-children' : ''}`;
|
||||||
item.dataset.id = session.id;
|
item.dataset.id = session.id;
|
||||||
const sessionCwd = getSessionEffectiveCwd(session);
|
const sessionCwd = getSessionEffectiveCwd(session);
|
||||||
if (sessionCwd) item.title = sessionCwd;
|
if (sessionCwd) item.title = sessionCwd;
|
||||||
@@ -5239,8 +5312,17 @@
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case 'session_renamed':
|
case 'session_renamed':
|
||||||
sessions = sessions.map((session) => session.id === msg.sessionId ? { ...session, title: msg.title } : session);
|
sessions = sessions.map((session) => session.id === msg.sessionId ? {
|
||||||
updateCachedSession(msg.sessionId, (snapshot) => { snapshot.title = msg.title; });
|
...session,
|
||||||
|
title: msg.title,
|
||||||
|
...(msg.titleSource !== undefined ? { titleSource: msg.titleSource || null } : {}),
|
||||||
|
...(msg.createdFromKind !== undefined ? { createdFromKind: msg.createdFromKind || null } : {}),
|
||||||
|
} : 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.sessionId === currentSessionId) {
|
if (msg.sessionId === currentSessionId) {
|
||||||
chatTitle.textContent = msg.title;
|
chatTitle.textContent = msg.title;
|
||||||
}
|
}
|
||||||
@@ -5348,6 +5430,7 @@
|
|||||||
|
|
||||||
case 'system_message':
|
case 'system_message':
|
||||||
if (!isCurrentSessionEvent(msg)) break;
|
if (!isCurrentSessionEvent(msg)) break;
|
||||||
|
applyPendingSlashDraftResponse(msg);
|
||||||
appendSystemMessage(msg.message, {
|
appendSystemMessage(msg.message, {
|
||||||
tone: msg.tone,
|
tone: msg.tone,
|
||||||
transient: msg.transient,
|
transient: msg.transient,
|
||||||
@@ -5512,6 +5595,7 @@
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (pendingNewSessionRequest) pendingNewSessionRequest = null;
|
if (pendingNewSessionRequest) pendingNewSessionRequest = null;
|
||||||
|
applyPendingSlashDraftResponse(msg);
|
||||||
appendError(msg.message, {
|
appendError(msg.message, {
|
||||||
transient: msg.transient,
|
transient: msg.transient,
|
||||||
autoDismissMs: msg.autoDismissMs,
|
autoDismissMs: msg.autoDismissMs,
|
||||||
@@ -8328,7 +8412,9 @@
|
|||||||
autoResize();
|
autoResize();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
send({ type: 'message', text, sessionId: currentSessionId, mode: currentMode, agent: currentAgent });
|
const requestId = createLocalId('slash');
|
||||||
|
rememberPendingSlashDraft(requestId, text, currentSessionId, currentAgent);
|
||||||
|
send({ type: 'message', text, sessionId: currentSessionId, mode: currentMode, agent: currentAgent, requestId });
|
||||||
msgInput.value = '';
|
msgInput.value = '';
|
||||||
autoResize();
|
autoResize();
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1318,6 +1318,18 @@ body.session-loading-active {
|
|||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
}
|
}
|
||||||
|
.session-item.llm-created:not(.pinned)::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 4px;
|
||||||
|
top: 7px;
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-top: 2px solid var(--accent);
|
||||||
|
border-left: 2px solid var(--accent);
|
||||||
|
border-radius: 4px 0 0 0;
|
||||||
|
opacity: 0.88;
|
||||||
|
}
|
||||||
.session-item-main {
|
.session-item-main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -5139,6 +5151,25 @@ html[data-theme='coolvibe'] .settings-back:hover {
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
}
|
}
|
||||||
|
.ccweb-prompt-clear-choice {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
justify-self: start;
|
||||||
|
border: 1px solid rgba(221, 208, 192, 0.9);
|
||||||
|
border-radius: 7px;
|
||||||
|
background: rgba(255, 255, 255, 0.56);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.25;
|
||||||
|
padding: 4px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.16s ease, color 0.16s ease, background 0.16s ease;
|
||||||
|
}
|
||||||
|
.ccweb-prompt-clear-choice:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: rgba(255, 249, 242, 0.96);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
.ccweb-prompt-option-label {
|
.ccweb-prompt-option-label {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
@@ -6372,7 +6403,14 @@ html[data-theme='coolvibe'] .settings-back:hover {
|
|||||||
border-color: var(--text-muted);
|
border-color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .ccweb-prompt-clear-choice {
|
||||||
|
background: rgba(0, 0, 0, 0.16);
|
||||||
|
border-color: var(--theme-card-hover-border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .ccweb-prompt-option:hover,
|
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .ccweb-prompt-option:hover,
|
||||||
|
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .ccweb-prompt-clear-choice:hover,
|
||||||
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .ccweb-prompt-tab-nav-btn:hover,
|
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .ccweb-prompt-tab-nav-btn:hover,
|
||||||
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .ccweb-prompt-secondary:hover,
|
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .ccweb-prompt-secondary:hover,
|
||||||
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .pending-ccweb-prompt-action:hover {
|
:is(html[data-theme='carbon'], html[data-theme='nocturne'], html[data-theme='cinder']) .pending-ccweb-prompt-action:hover {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const REPO_DIR = path.resolve(__dirname, '..');
|
|||||||
const SERVER_PATH = path.join(REPO_DIR, 'server.js');
|
const SERVER_PATH = path.join(REPO_DIR, 'server.js');
|
||||||
const PUBLIC_APP_PATH = path.join(REPO_DIR, 'public', 'app.js');
|
const PUBLIC_APP_PATH = path.join(REPO_DIR, 'public', 'app.js');
|
||||||
const PUBLIC_INDEX_PATH = path.join(REPO_DIR, 'public', 'index.html');
|
const PUBLIC_INDEX_PATH = path.join(REPO_DIR, 'public', 'index.html');
|
||||||
|
const PUBLIC_STYLE_PATH = path.join(REPO_DIR, 'public', 'style.css');
|
||||||
const MOCK_CLAUDE = path.join(REPO_DIR, 'scripts', 'mock-claude.js');
|
const MOCK_CLAUDE = path.join(REPO_DIR, 'scripts', 'mock-claude.js');
|
||||||
const MOCK_CODEX = path.join(REPO_DIR, 'scripts', 'mock-codex.js');
|
const MOCK_CODEX = path.join(REPO_DIR, 'scripts', 'mock-codex.js');
|
||||||
const MOCK_CODEX_APP_SERVER = path.join(REPO_DIR, 'scripts', 'mock-codex-app-server.js');
|
const MOCK_CODEX_APP_SERVER = path.join(REPO_DIR, 'scripts', 'mock-codex-app-server.js');
|
||||||
@@ -563,6 +564,34 @@ function assertFrontendComposerMcpContract() {
|
|||||||
assert(source.includes("className = 'msg-mentions'"), 'Frontend should render a dedicated mention strip container');
|
assert(source.includes("className = 'msg-mentions'"), 'Frontend should render a dedicated mention strip container');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assertFrontendSlashDraftPreservationContract() {
|
||||||
|
const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||||||
|
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
|
||||||
|
assert(source.includes('const pendingSlashDraftsByRequestId = new Map();'), 'Frontend should keep request-scoped slash drafts');
|
||||||
|
assert(source.includes('function rememberPendingSlashDraft(requestId, text, sessionId, agent)'), 'Frontend should define slash draft capture');
|
||||||
|
assert(source.includes('function applyPendingSlashDraftResponse(msg)'), 'Frontend should define slash draft response handling');
|
||||||
|
assert(source.includes('if (!msg.preserveComposerDraft) return false;'), 'Frontend should restore slash drafts only for explicit failure responses');
|
||||||
|
assert(source.includes("if (msgInput.value !== '' || pendingAttachments.length > 0) return false;"), 'Frontend should not overwrite user input typed after a slash request');
|
||||||
|
assert(source.includes('rememberPendingSlashDraft(requestId, text, currentSessionId, currentAgent);'), 'Frontend should remember slash draft before clearing the composer');
|
||||||
|
assert(source.includes("send({ type: 'message', text, sessionId: currentSessionId, mode: currentMode, agent: currentAgent, requestId });"), 'Frontend slash sends should carry requestId');
|
||||||
|
assert((source.match(/applyPendingSlashDraftResponse\(msg\);/g) || []).length >= 2, 'Frontend should handle slash draft restoration on system_message and error');
|
||||||
|
|
||||||
|
const sendStart = source.indexOf('function sendMessage()');
|
||||||
|
const slashStart = source.indexOf("if (text.startsWith('/'))", sendStart);
|
||||||
|
const rememberStart = source.indexOf('rememberPendingSlashDraft(requestId, text, currentSessionId, currentAgent);', slashStart);
|
||||||
|
const modelPickerStart = source.indexOf("if (text === '/model' || text === '/model ')", slashStart);
|
||||||
|
const modePickerStart = source.indexOf("if (text === '/mode' || text === '/mode ')", slashStart);
|
||||||
|
assert(slashStart >= 0 && rememberStart > slashStart, 'Frontend should keep slash send branch explicit');
|
||||||
|
assert(modelPickerStart > slashStart && modelPickerStart < rememberStart, 'Frontend /model picker should stay local and clear normally');
|
||||||
|
assert(modePickerStart > slashStart && modePickerStart < rememberStart, 'Frontend /mode picker should stay local and clear normally');
|
||||||
|
|
||||||
|
assert(serverSource.includes('handleSlashCommand(ws, msg.text.trim(), msg.sessionId, msg.agent, msg);'), 'Server should pass client request metadata into slash handler');
|
||||||
|
assert(serverSource.includes('function handleSlashCommand(ws, text, sessionId, fallbackAgent, source = {})'), 'Server slash handler should accept request metadata');
|
||||||
|
assert(serverSource.includes('wsSend(ws, attachClientRequestId(base, source));'), 'Server slash responses should echo requestId');
|
||||||
|
assert(serverSource.includes('preserveComposerDraft: true'), 'Server should explicitly mark slash failures as draft-preserving');
|
||||||
|
assert(serverSource.includes('wsSend(ws, attachClientRequestId({') && serverSource.includes('...(msg.preserveComposerDraft ? { preserveComposerDraft: true } : {})'), 'Server runtime errors should echo requestId and draft-preservation metadata');
|
||||||
|
}
|
||||||
|
|
||||||
function assertMockCodexAppPromptUserNotTextTriggered() {
|
function assertMockCodexAppPromptUserNotTextTriggered() {
|
||||||
const source = fs.readFileSync(MOCK_CODEX_APP_SERVER, 'utf8');
|
const source = fs.readFileSync(MOCK_CODEX_APP_SERVER, 'utf8');
|
||||||
assert(!source.includes('codexapp runtime prompt mcp'), 'Mock Codex App should not expose a text-triggered ccweb_prompt_user path');
|
assert(!source.includes('codexapp runtime prompt mcp'), 'Mock Codex App should not expose a text-triggered ccweb_prompt_user path');
|
||||||
@@ -589,6 +618,10 @@ function assertFrontendCcwebPromptContract() {
|
|||||||
assert(source.includes('card.dataset.viewMode = normalized'), 'Frontend should switch ccweb prompt card view mode');
|
assert(source.includes('card.dataset.viewMode = normalized'), 'Frontend should switch ccweb prompt card view mode');
|
||||||
assert(source.includes('m.ccwebPrompt'), 'Message rebuild should render persisted ccweb prompt messages');
|
assert(source.includes('m.ccwebPrompt'), 'Message rebuild should render persisted ccweb prompt messages');
|
||||||
assert(source.includes("className = 'ccweb-prompt-answer'"), 'Each ccweb prompt question should expose an editable answer textarea');
|
assert(source.includes("className = 'ccweb-prompt-answer'"), 'Each ccweb prompt question should expose an editable answer textarea');
|
||||||
|
assert(source.includes('function clearCcwebPromptSelection(questionEl, question)'), 'ccweb prompt options should expose an explicit no-selection action');
|
||||||
|
assert(source.includes("clearChoice.textContent = '不选择';"), 'ccweb prompt should render a visible no-selection control');
|
||||||
|
assert(source.includes("const shouldDeselect = buttons.some"), 'Single-select ccweb prompt options should allow clicking the selected option to deselect');
|
||||||
|
assert(source.includes("textarea.dataset.ccwebPromptSelectionText"), 'ccweb prompt should clear only option-generated answer text when selections are removed');
|
||||||
}
|
}
|
||||||
|
|
||||||
function assertFrontendMarkdownLinkContract() {
|
function assertFrontendMarkdownLinkContract() {
|
||||||
@@ -614,6 +647,21 @@ function assertFrontendMcpReloadContract() {
|
|||||||
assert(source.includes('MCP 启动失败'), 'Frontend should expose a failed startup toast');
|
assert(source.includes('MCP 启动失败'), 'Frontend should expose a failed startup toast');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function assertSetTitleMcpContract() {
|
||||||
|
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
|
||||||
|
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||||||
|
const styleSource = fs.readFileSync(PUBLIC_STYLE_PATH, 'utf8');
|
||||||
|
|
||||||
|
assert(serverSource.includes("case 'ccweb_set_title':"), 'Server should route ccweb_set_title through internal MCP');
|
||||||
|
assert(serverSource.includes('function setCurrentConversationTitle'), 'Server should implement current-conversation title setting');
|
||||||
|
assert(serverSource.includes('ignored because title is manually locked'), 'ccweb_set_title should report manual title locks as ignored success');
|
||||||
|
assert(serverSource.includes("titleSource = 'manual'"), 'Manual UI rename should mark titleSource as manual');
|
||||||
|
assert(serverSource.includes('CCWEB_TITLE_TOOL_INSTRUCTIONS'), 'Server should define model guidance for the title tool');
|
||||||
|
assert(frontendSource.includes("createdFromKind || ''") && frontendSource.includes("' llm-created'"), 'Frontend should mark MCP-created sessions with llm-created class');
|
||||||
|
assert(frontendSource.includes('snapshot.titleSource') && frontendSource.includes('snapshot.createdFromKind'), 'Frontend should preserve title metadata in session snapshots');
|
||||||
|
assert(styleSource.includes('.session-item.llm-created:not(.pinned)::before'), 'LLM-created marker should be hidden when the session is pinned');
|
||||||
|
}
|
||||||
|
|
||||||
function assertSessionSwitchResilienceContract() {
|
function assertSessionSwitchResilienceContract() {
|
||||||
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||||||
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
|
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
|
||||||
@@ -713,10 +761,12 @@ function assertSessionSwitchResilienceContract() {
|
|||||||
async function main() {
|
async function main() {
|
||||||
assertFrontendGenerationControlsContract();
|
assertFrontendGenerationControlsContract();
|
||||||
assertFrontendComposerMcpContract();
|
assertFrontendComposerMcpContract();
|
||||||
|
assertFrontendSlashDraftPreservationContract();
|
||||||
assertFrontendCcwebPromptContract();
|
assertFrontendCcwebPromptContract();
|
||||||
assertFrontendMarkdownLinkContract();
|
assertFrontendMarkdownLinkContract();
|
||||||
assertMockCodexAppPromptUserNotTextTriggered();
|
assertMockCodexAppPromptUserNotTextTriggered();
|
||||||
assertFrontendMcpReloadContract();
|
assertFrontendMcpReloadContract();
|
||||||
|
assertSetTitleMcpContract();
|
||||||
assertSessionSwitchResilienceContract();
|
assertSessionSwitchResilienceContract();
|
||||||
|
|
||||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-regression-'));
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-regression-'));
|
||||||
@@ -969,6 +1019,7 @@ async function main() {
|
|||||||
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-slash-mcp', trigger: '/', query: 'ccweb', sessionId: codexSession.sessionId, agent: 'codex' }));
|
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-slash-mcp', trigger: '/', query: 'ccweb', sessionId: codexSession.sessionId, agent: 'codex' }));
|
||||||
const slashMcpComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-slash-mcp');
|
const slashMcpComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-slash-mcp');
|
||||||
assert(slashMcpComposer.items.some((item) => item.kind === 'mcp' && item.name === 'ccweb_list_conversations'), 'Composer slash suggestions should include ccweb MCP tools');
|
assert(slashMcpComposer.items.some((item) => item.kind === 'mcp' && item.name === 'ccweb_list_conversations'), 'Composer slash suggestions should include ccweb MCP tools');
|
||||||
|
assert(slashMcpComposer.items.some((item) => item.kind === 'mcp' && item.name === 'ccweb_set_title'), 'Composer slash suggestions should include ccweb_set_title MCP tool');
|
||||||
|
|
||||||
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-slash-mcp-config', trigger: '/', query: 'reg', sessionId: codexSession.sessionId, agent: 'codex' }));
|
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-slash-mcp-config', trigger: '/', query: 'reg', sessionId: codexSession.sessionId, agent: 'codex' }));
|
||||||
const slashMcpConfigComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-slash-mcp-config');
|
const slashMcpConfigComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-slash-mcp-config');
|
||||||
@@ -990,6 +1041,17 @@ async function main() {
|
|||||||
assert(!slashMcpRuntimeComposer.items.some((item) => item.kind === 'mcp' && item.itemType === 'server' && item.name === 'regRuntime'), 'Composer slash suggestions should not infer MCP servers from session tool names');
|
assert(!slashMcpRuntimeComposer.items.some((item) => item.kind === 'mcp' && item.itemType === 'server' && item.name === 'regRuntime'), 'Composer slash suggestions should not infer MCP servers from session tool names');
|
||||||
assert(!slashMcpRuntimeComposer.items.some((item) => item.kind === 'mcp' && item.itemType === 'server' && item.name === 'reg-state'), 'Composer slash suggestions should not infer MCP servers from mcp:server labels');
|
assert(!slashMcpRuntimeComposer.items.some((item) => item.kind === 'mcp' && item.itemType === 'server' && item.name === 'reg-state'), 'Composer slash suggestions should not infer MCP servers from mcp:server labels');
|
||||||
|
|
||||||
|
ws.send(JSON.stringify({ type: 'message', text: '/grilling', sessionId: codexSession.sessionId, mode: 'plan', agent: 'codex', requestId: 'reg-unknown-slash-draft' }));
|
||||||
|
const unknownSlashDraft = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.requestId === 'reg-unknown-slash-draft');
|
||||||
|
assert(unknownSlashDraft.sessionId === codexSession.sessionId, 'Unknown slash draft response should stay scoped to the active session');
|
||||||
|
assert(unknownSlashDraft.preserveComposerDraft === true, 'Unknown slash command should tell the frontend to restore the composer draft');
|
||||||
|
assert(/未知指令: \/grilling/.test(unknownSlashDraft.message || ''), 'Unknown slash command should still show the normal failure hint');
|
||||||
|
|
||||||
|
ws.send(JSON.stringify({ type: 'message', text: '/help', sessionId: codexSession.sessionId, mode: 'plan', agent: 'codex', requestId: 'reg-help-slash-draft' }));
|
||||||
|
const helpSlashDraft = await nextMessage(messages, ws, (msg) => msg.type === 'system_message' && msg.requestId === 'reg-help-slash-draft');
|
||||||
|
assert(!helpSlashDraft.preserveComposerDraft, 'Successful slash command responses should clear pending drafts without restoring input');
|
||||||
|
assert(/可用指令/.test(helpSlashDraft.message || ''), 'Slash /help should keep the normal help output');
|
||||||
|
|
||||||
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-skill', trigger: '$', query: 'reg', sessionId: codexSession.sessionId, agent: 'codex' }));
|
ws.send(JSON.stringify({ type: 'composer_suggestions', requestId: 'reg-skill', trigger: '$', query: 'reg', sessionId: codexSession.sessionId, agent: 'codex' }));
|
||||||
const skillComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-skill');
|
const skillComposer = await nextMessage(messages, ws, (msg) => msg.type === 'composer_suggestions' && msg.requestId === 'reg-skill');
|
||||||
assert(skillComposer.items.some((item) => item.kind === 'skill' && item.name === 'regression-skill'), 'Composer skill suggestions should include local Codex skill');
|
assert(skillComposer.items.some((item) => item.kind === 'skill' && item.name === 'regression-skill'), 'Composer skill suggestions should include local Codex skill');
|
||||||
@@ -1066,6 +1128,72 @@ async function main() {
|
|||||||
assert(mcpList.body.currentConversationId === codexSession.sessionId, 'MCP list should return current source conversation id');
|
assert(mcpList.body.currentConversationId === codexSession.sessionId, 'MCP list should return current source conversation id');
|
||||||
assert(mcpList.body.conversations.some((item) => item.id === codexSession.sessionId && !item.summary), 'MCP list should return lightweight session metadata without summary');
|
assert(mcpList.body.conversations.some((item) => item.id === codexSession.sessionId && !item.summary), 'MCP list should return lightweight session metadata without summary');
|
||||||
|
|
||||||
|
const codexTitleSessionPath = path.join(sessionsDir, `${codexSession.sessionId}.json`);
|
||||||
|
const storedBeforeMcpTitle = JSON.parse(fs.readFileSync(codexTitleSessionPath, 'utf8'));
|
||||||
|
const mcpSetTitle = await callInternalMcp(port, internalMcpToken, {
|
||||||
|
tool: 'ccweb_set_title',
|
||||||
|
sourceSessionId: codexSession.sessionId,
|
||||||
|
args: { title: 'Concise MCP Title' },
|
||||||
|
});
|
||||||
|
assert(mcpSetTitle.status === 200 && mcpSetTitle.body?.ok, `MCP set title should succeed: ${JSON.stringify(mcpSetTitle.body)}`);
|
||||||
|
assert(mcpSetTitle.body.changed === true && mcpSetTitle.body.ignored === false, 'MCP set title should report a real title change');
|
||||||
|
assert(mcpSetTitle.body.previousTitle === storedBeforeMcpTitle.title, 'MCP set title should return previousTitle');
|
||||||
|
assert(mcpSetTitle.body.lockedByUser === false, 'MCP set title should not report a user lock before manual rename');
|
||||||
|
const mcpTitleRenamed = await nextMessage(messages, ws, (msg) => (
|
||||||
|
msg.type === 'session_renamed' &&
|
||||||
|
msg.sessionId === codexSession.sessionId &&
|
||||||
|
msg.title === 'Concise MCP Title'
|
||||||
|
));
|
||||||
|
assert(mcpTitleRenamed.titleSource === 'llm', 'MCP set title should push llm titleSource to current viewers');
|
||||||
|
const mcpTitleList = await nextMessage(messages, ws, (msg) => (
|
||||||
|
msg.type === 'session_list' &&
|
||||||
|
msg.sessions.some((session) => (
|
||||||
|
session.id === codexSession.sessionId &&
|
||||||
|
session.title === 'Concise MCP Title' &&
|
||||||
|
session.titleSource === 'llm'
|
||||||
|
))
|
||||||
|
));
|
||||||
|
assert(mcpTitleList.sessions.some((session) => session.id === codexSession.sessionId && session.updated === storedBeforeMcpTitle.updated), 'MCP set title should not bump session updated sorting timestamp');
|
||||||
|
const storedAfterMcpTitle = JSON.parse(fs.readFileSync(codexTitleSessionPath, 'utf8'));
|
||||||
|
assert(storedAfterMcpTitle.title === 'Concise MCP Title', 'MCP set title should persist title');
|
||||||
|
assert(storedAfterMcpTitle.titleSource === 'llm', 'MCP set title should persist llm titleSource');
|
||||||
|
assert(storedAfterMcpTitle.updated === storedBeforeMcpTitle.updated, 'MCP set title should not modify updated timestamp');
|
||||||
|
|
||||||
|
const mcpEmptyTitle = await callInternalMcp(port, internalMcpToken, {
|
||||||
|
tool: 'ccweb_set_title',
|
||||||
|
sourceSessionId: codexSession.sessionId,
|
||||||
|
args: { title: ' ' },
|
||||||
|
});
|
||||||
|
assert(mcpEmptyTitle.status === 400 && mcpEmptyTitle.body?.code === 'invalid_title', 'MCP set title should reject empty titles');
|
||||||
|
|
||||||
|
ws.send(JSON.stringify({ type: 'rename_session', sessionId: codexSession.sessionId, title: 'Manual Locked Title' }));
|
||||||
|
const manualTitleRenamed = await nextMessage(messages, ws, (msg) => (
|
||||||
|
msg.type === 'session_renamed' &&
|
||||||
|
msg.sessionId === codexSession.sessionId &&
|
||||||
|
msg.title === 'Manual Locked Title'
|
||||||
|
));
|
||||||
|
assert(manualTitleRenamed.titleSource === 'manual', 'Manual rename should push manual titleSource');
|
||||||
|
await nextMessage(messages, ws, (msg) => (
|
||||||
|
msg.type === 'session_list' &&
|
||||||
|
msg.sessions.some((session) => (
|
||||||
|
session.id === codexSession.sessionId &&
|
||||||
|
session.title === 'Manual Locked Title' &&
|
||||||
|
session.titleSource === 'manual'
|
||||||
|
))
|
||||||
|
));
|
||||||
|
const mcpLockedTitle = await callInternalMcp(port, internalMcpToken, {
|
||||||
|
tool: 'ccweb_set_title',
|
||||||
|
sourceSessionId: codexSession.sessionId,
|
||||||
|
args: { title: 'Ignored LLM Title' },
|
||||||
|
});
|
||||||
|
assert(mcpLockedTitle.status === 200 && mcpLockedTitle.body?.ok, 'MCP set title should return ok when manually locked');
|
||||||
|
assert(mcpLockedTitle.body.changed === false && mcpLockedTitle.body.ignored === true, 'MCP set title should report ignored under manual lock');
|
||||||
|
assert(mcpLockedTitle.body.reason === 'ignored because title is manually locked', 'MCP set title should explain manual lock ignores');
|
||||||
|
assert(mcpLockedTitle.body.title === 'Manual Locked Title' && mcpLockedTitle.body.lockedByUser === true, 'MCP set title should preserve manual title under lock');
|
||||||
|
const storedAfterManualLock = JSON.parse(fs.readFileSync(codexTitleSessionPath, 'utf8'));
|
||||||
|
assert(storedAfterManualLock.title === 'Manual Locked Title', 'Manual locked title should remain persisted after ignored MCP set title');
|
||||||
|
assert(storedAfterManualLock.titleSource === 'manual', 'Manual locked titleSource should remain manual after ignored MCP set title');
|
||||||
|
|
||||||
const mcpRelativeCreate = await callInternalMcp(port, internalMcpToken, {
|
const mcpRelativeCreate = await callInternalMcp(port, internalMcpToken, {
|
||||||
tool: 'ccweb_create_conversation',
|
tool: 'ccweb_create_conversation',
|
||||||
sourceSessionId: codexSession.sessionId,
|
sourceSessionId: codexSession.sessionId,
|
||||||
@@ -1092,11 +1220,20 @@ async function main() {
|
|||||||
await nextMessage(messages, ws, (msg) => msg.type === 'background_done' && msg.sessionId === mcpCreate.body.conversationId);
|
await nextMessage(messages, ws, (msg) => msg.type === 'background_done' && msg.sessionId === mcpCreate.body.conversationId);
|
||||||
const storedMcpCreated = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${mcpCreate.body.conversationId}.json`), 'utf8'));
|
const storedMcpCreated = JSON.parse(fs.readFileSync(path.join(sessionsDir, `${mcpCreate.body.conversationId}.json`), 'utf8'));
|
||||||
assert(storedMcpCreated.title === 'MCP Created Conversation', 'MCP created conversation should persist the requested title');
|
assert(storedMcpCreated.title === 'MCP Created Conversation', 'MCP created conversation should persist the requested title');
|
||||||
|
assert(storedMcpCreated.titleSource === 'system', 'MCP created conversation title should not be treated as user manual title');
|
||||||
assert(storedMcpCreated.agent === 'codex', 'MCP created conversation should persist the inherited source agent');
|
assert(storedMcpCreated.agent === 'codex', 'MCP created conversation should persist the inherited source agent');
|
||||||
assert(storedMcpCreated.permissionMode === 'yolo', 'MCP created conversation should persist yolo as the default mode');
|
assert(storedMcpCreated.permissionMode === 'yolo', 'MCP created conversation should persist yolo as the default mode');
|
||||||
|
assert(storedMcpCreated.createdFrom?.kind === 'mcp', 'MCP created conversation should persist mcp creation kind');
|
||||||
assert(storedMcpCreated.createdFrom?.sourceSessionId === codexSession.sessionId, 'MCP created conversation should persist source metadata');
|
assert(storedMcpCreated.createdFrom?.sourceSessionId === codexSession.sessionId, 'MCP created conversation should persist source metadata');
|
||||||
assert(storedMcpCreated.messages.some((message) => message.content === 'mcp created initial prompt' && message.crossConversation?.sourceSessionId === codexSession.sessionId), 'MCP created conversation should persist the initial cross-conversation message');
|
assert(storedMcpCreated.messages.some((message) => message.content === 'mcp created initial prompt' && message.crossConversation?.sourceSessionId === codexSession.sessionId), 'MCP created conversation should persist the initial cross-conversation message');
|
||||||
assert(storedMcpCreated.messages.some((message) => message.role === 'assistant' && /mcp created initial prompt/.test(String(message.content || ''))), 'MCP created conversation should run the initial prompt');
|
assert(storedMcpCreated.messages.some((message) => message.role === 'assistant' && /mcp created initial prompt/.test(String(message.content || ''))), 'MCP created conversation should run the initial prompt');
|
||||||
|
await nextMessage(messages, ws, (msg) => (
|
||||||
|
msg.type === 'session_list' &&
|
||||||
|
msg.sessions.some((session) => (
|
||||||
|
session.id === mcpCreate.body.conversationId &&
|
||||||
|
session.createdFromKind === 'mcp'
|
||||||
|
))
|
||||||
|
));
|
||||||
|
|
||||||
const mcpReplyCreateCwd = path.join(tempRoot, 'mcp-create-reply');
|
const mcpReplyCreateCwd = path.join(tempRoot, 'mcp-create-reply');
|
||||||
mkdirp(mcpReplyCreateCwd);
|
mkdirp(mcpReplyCreateCwd);
|
||||||
|
|||||||
301
server.js
301
server.js
@@ -705,6 +705,7 @@ let MODEL_MAP = {
|
|||||||
|
|
||||||
const VALID_AGENTS = new Set(['claude', 'codex', 'codexapp']);
|
const VALID_AGENTS = new Set(['claude', 'codex', 'codexapp']);
|
||||||
const VALID_PERMISSION_MODES = new Set(['default', 'plan', 'yolo']);
|
const VALID_PERMISSION_MODES = new Set(['default', 'plan', 'yolo']);
|
||||||
|
const VALID_TITLE_SOURCES = new Set(['derived', 'llm', 'manual', 'system']);
|
||||||
const MCP_CONVERSATION_TITLE_MAX_CHARS = 120;
|
const MCP_CONVERSATION_TITLE_MAX_CHARS = 120;
|
||||||
const MCP_PROMPT_TITLE_MAX_CHARS = 160;
|
const MCP_PROMPT_TITLE_MAX_CHARS = 160;
|
||||||
const MCP_PROMPT_DESCRIPTION_MAX_CHARS = 2000;
|
const MCP_PROMPT_DESCRIPTION_MAX_CHARS = 2000;
|
||||||
@@ -718,7 +719,12 @@ const MCP_PROMPT_RESPONSE_MAX_CHARS = 20000;
|
|||||||
// Codex 默认模型优先读取 ~/.codex/config.toml,缺失时再回退到旧默认值。
|
// Codex 默认模型优先读取 ~/.codex/config.toml,缺失时再回退到旧默认值。
|
||||||
const FALLBACK_CODEX_MODEL = 'gpt-5.4';
|
const FALLBACK_CODEX_MODEL = 'gpt-5.4';
|
||||||
const CODEX_REASONING_LEVELS = new Set(['low', 'medium', 'high', 'xhigh']);
|
const CODEX_REASONING_LEVELS = new Set(['low', 'medium', 'high', 'xhigh']);
|
||||||
|
const CCWEB_TITLE_TOOL_INSTRUCTIONS = [
|
||||||
|
'Use the ccweb title tool sparingly. For a new chat, call "mcp__ccweb__ccweb_set_title" or "ccweb_set_title" once after the user\'s initial request is clear, and set a concise task title.',
|
||||||
|
'Do not rename the chat for routine progress, substeps, implementation details, or slightly better wording. Rename only when the user\'s primary objective changes substantially and the existing title would be misleading.',
|
||||||
|
].join('\n');
|
||||||
const CODEX_APP_COLLABORATION_INSTRUCTIONS = [
|
const CODEX_APP_COLLABORATION_INSTRUCTIONS = [
|
||||||
|
CCWEB_TITLE_TOOL_INSTRUCTIONS,
|
||||||
'Codex sub-agent spawning rules:',
|
'Codex sub-agent spawning rules:',
|
||||||
'- Treat omitted fork_context the same as fork_context: true: a full-history fork inherits the parent agent type, model, and reasoning effort.',
|
'- Treat omitted fork_context the same as fork_context: true: a full-history fork inherits the parent agent type, model, and reasoning effort.',
|
||||||
'- If you call spawn_agent with fork_context omitted or true, do not set agent_type, model, or reasoning_effort.',
|
'- If you call spawn_agent with fork_context omitted or true, do not set agent_type, model, or reasoning_effort.',
|
||||||
@@ -2889,6 +2895,12 @@ function normalizeSession(session) {
|
|||||||
session.agent = normalizeAgent(session.agent);
|
session.agent = normalizeAgent(session.agent);
|
||||||
if (!Object.prototype.hasOwnProperty.call(session, 'pinnedAt')) session.pinnedAt = null;
|
if (!Object.prototype.hasOwnProperty.call(session, 'pinnedAt')) session.pinnedAt = null;
|
||||||
if (session.pinnedAt && Number.isNaN(new Date(session.pinnedAt).getTime())) session.pinnedAt = null;
|
if (session.pinnedAt && Number.isNaN(new Date(session.pinnedAt).getTime())) session.pinnedAt = null;
|
||||||
|
const titleSource = String(session.titleSource || '').trim().toLowerCase();
|
||||||
|
if (titleSource && VALID_TITLE_SOURCES.has(titleSource)) {
|
||||||
|
session.titleSource = titleSource;
|
||||||
|
} else if (Object.prototype.hasOwnProperty.call(session, 'titleSource')) {
|
||||||
|
delete session.titleSource;
|
||||||
|
}
|
||||||
if (!Object.prototype.hasOwnProperty.call(session, 'claudeSessionId')) session.claudeSessionId = null;
|
if (!Object.prototype.hasOwnProperty.call(session, 'claudeSessionId')) session.claudeSessionId = null;
|
||||||
if (!Object.prototype.hasOwnProperty.call(session, 'codexThreadId')) session.codexThreadId = null;
|
if (!Object.prototype.hasOwnProperty.call(session, 'codexThreadId')) session.codexThreadId = null;
|
||||||
if (!Object.prototype.hasOwnProperty.call(session, 'codexAppThreadId')) session.codexAppThreadId = null;
|
if (!Object.prototype.hasOwnProperty.call(session, 'codexAppThreadId')) session.codexAppThreadId = null;
|
||||||
@@ -3660,6 +3672,18 @@ function jsonStringFieldFromPreview(text, key) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function jsonNestedStringFieldFromPreview(text, objectKey, key) {
|
||||||
|
const pattern = new RegExp(`"${objectKey}"\\s*:\\s*\\{[\\s\\S]{0,1200}?"${key}"\\s*:\\s*("(?:(?:\\\\.)|[^"\\\\])*"|null)`);
|
||||||
|
const match = pattern.exec(text);
|
||||||
|
if (!match) return null;
|
||||||
|
if (match[1] === 'null') return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(match[1]);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function jsonBooleanFieldFromPreview(text, key) {
|
function jsonBooleanFieldFromPreview(text, key) {
|
||||||
const pattern = new RegExp(`"${key}"\\s*:\\s*(true|false)`);
|
const pattern = new RegExp(`"${key}"\\s*:\\s*(true|false)`);
|
||||||
const match = pattern.exec(text);
|
const match = pattern.exec(text);
|
||||||
@@ -3695,6 +3719,8 @@ function loadSessionMetaFromFile(filePath) {
|
|||||||
updated: session.updated || session.created || stat.mtime.toISOString(),
|
updated: session.updated || session.created || stat.mtime.toISOString(),
|
||||||
created: session.created || null,
|
created: session.created || null,
|
||||||
pinnedAt: session.pinnedAt || null,
|
pinnedAt: session.pinnedAt || null,
|
||||||
|
titleSource: session.titleSource || null,
|
||||||
|
createdFromKind: sessionCreatedFromKind(session) || null,
|
||||||
hasUnread: !!session.hasUnread,
|
hasUnread: !!session.hasUnread,
|
||||||
agent: getSessionAgent(session),
|
agent: getSessionAgent(session),
|
||||||
cwd,
|
cwd,
|
||||||
@@ -3712,6 +3738,8 @@ function loadSessionMetaFromFile(filePath) {
|
|||||||
updated: jsonStringFieldFromPreview(preview, 'updated') || jsonStringFieldFromPreview(preview, 'updatedAt') || stat.mtime.toISOString(),
|
updated: jsonStringFieldFromPreview(preview, 'updated') || jsonStringFieldFromPreview(preview, 'updatedAt') || stat.mtime.toISOString(),
|
||||||
created: jsonStringFieldFromPreview(preview, 'created') || null,
|
created: jsonStringFieldFromPreview(preview, 'created') || null,
|
||||||
pinnedAt: jsonStringFieldFromPreview(preview, 'pinnedAt') || null,
|
pinnedAt: jsonStringFieldFromPreview(preview, 'pinnedAt') || null,
|
||||||
|
titleSource: jsonStringFieldFromPreview(preview, 'titleSource') || null,
|
||||||
|
createdFromKind: jsonNestedStringFieldFromPreview(preview, 'createdFrom', 'kind') || null,
|
||||||
hasUnread: jsonBooleanFieldFromPreview(preview, 'hasUnread'),
|
hasUnread: jsonBooleanFieldFromPreview(preview, 'hasUnread'),
|
||||||
agent: normalizeAgent(jsonStringFieldFromPreview(preview, 'agent')),
|
agent: normalizeAgent(jsonStringFieldFromPreview(preview, 'agent')),
|
||||||
cwd,
|
cwd,
|
||||||
@@ -4357,6 +4385,8 @@ function sendSessionList(ws) {
|
|||||||
title: meta.title || 'Untitled',
|
title: meta.title || 'Untitled',
|
||||||
updated: meta.updated,
|
updated: meta.updated,
|
||||||
pinnedAt: meta.pinnedAt || null,
|
pinnedAt: meta.pinnedAt || null,
|
||||||
|
titleSource: meta.titleSource || null,
|
||||||
|
createdFromKind: meta.createdFromKind || null,
|
||||||
hasUnread: !!meta.hasUnread,
|
hasUnread: !!meta.hasUnread,
|
||||||
agent: normalizeAgent(meta.agent),
|
agent: normalizeAgent(meta.agent),
|
||||||
cwd: meta.cwd || '',
|
cwd: meta.cwd || '',
|
||||||
@@ -5135,6 +5165,8 @@ function callInternalMcpTool(tool, args, sourceSessionId, sourceHopCount) {
|
|||||||
switch (tool) {
|
switch (tool) {
|
||||||
case 'ccweb_list_conversations':
|
case 'ccweb_list_conversations':
|
||||||
return listConversationSummaries(args, sanitizeId(sourceSessionId || ''));
|
return listConversationSummaries(args, sanitizeId(sourceSessionId || ''));
|
||||||
|
case 'ccweb_set_title':
|
||||||
|
return setCurrentConversationTitle(args, sourceSessionId);
|
||||||
case 'ccweb_create_conversation':
|
case 'ccweb_create_conversation':
|
||||||
return createMcpConversation(args, sourceSessionId, sourceHopCount);
|
return createMcpConversation(args, sourceSessionId, sourceHopCount);
|
||||||
case 'ccweb_send_message':
|
case 'ccweb_send_message':
|
||||||
@@ -6268,7 +6300,7 @@ wss.on('connection', (ws, req) => {
|
|||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case 'message':
|
case 'message':
|
||||||
if (msg.text && msg.text.trim().startsWith('/')) {
|
if (msg.text && msg.text.trim().startsWith('/')) {
|
||||||
handleSlashCommand(ws, msg.text.trim(), msg.sessionId, msg.agent);
|
handleSlashCommand(ws, msg.text.trim(), msg.sessionId, msg.agent, msg);
|
||||||
} else {
|
} else {
|
||||||
handleMessage(ws, msg);
|
handleMessage(ws, msg);
|
||||||
}
|
}
|
||||||
@@ -6817,24 +6849,33 @@ function cancelCodexAppGoalCommand(sessionId, ws = null) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCodexAppGoalSlashCommand(ws, text, session) {
|
async function handleCodexAppGoalSlashCommand(ws, text, session, source = {}) {
|
||||||
const command = parseCodexGoalCommand(text);
|
const command = parseCodexGoalCommand(text);
|
||||||
if (!command) return;
|
if (!command) return;
|
||||||
|
const sendGoalResponse = (targetWs, payload, options = {}) => {
|
||||||
|
wsSend(targetWs, attachClientRequestId({
|
||||||
|
...payload,
|
||||||
|
...(options.preserveComposerDraft ? { preserveComposerDraft: true } : {}),
|
||||||
|
}, source));
|
||||||
|
};
|
||||||
|
const sendGoalSystemMessage = (targetWs, message, extra = {}, options = {}) => {
|
||||||
|
sendGoalResponse(targetWs, { type: 'system_message', message, ...extra }, options);
|
||||||
|
};
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
wsSend(ws, { type: 'system_message', message: '请先进入一个 Codex App 会话后再执行 /goal。' });
|
sendGoalSystemMessage(ws, '请先进入一个 Codex App 会话后再执行 /goal。', {}, { preserveComposerDraft: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isCodexAppSession(session)) {
|
if (!isCodexAppSession(session)) {
|
||||||
wsSend(ws, { type: 'system_message', message: '当前 /goal 仅支持 Codex App 会话。旧 Codex/Claude 会话没有 app-server goal RPC。' });
|
sendGoalSystemMessage(ws, '当前 /goal 仅支持 Codex App 会话。旧 Codex/Claude 会话没有 app-server goal RPC。', { sessionId: session.id }, { preserveComposerDraft: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (command.error) {
|
if (command.error) {
|
||||||
wsSend(ws, { type: 'system_message', sessionId: session.id, message: command.error });
|
sendGoalSystemMessage(ws, command.error, { sessionId: session.id }, { preserveComposerDraft: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (activeCodexAppGoalCommands.has(session.id)) {
|
if (activeCodexAppGoalCommands.has(session.id)) {
|
||||||
wsSend(ws, { type: 'system_message', sessionId: session.id, message: 'Codex App Goal 正在同步,请稍候。' });
|
sendGoalSystemMessage(ws, 'Codex App Goal 正在同步,请稍候。', { sessionId: session.id }, { preserveComposerDraft: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6846,7 +6887,7 @@ async function handleCodexAppGoalSlashCommand(ws, text, session) {
|
|||||||
startedAt: new Date().toISOString(),
|
startedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
activeCodexAppGoalCommands.set(session.id, activeGoalCommand);
|
activeCodexAppGoalCommands.set(session.id, activeGoalCommand);
|
||||||
wsSend(ws, { type: 'system_message', sessionId: session.id, message: '正在同步 Goal...' });
|
sendGoalSystemMessage(ws, '正在同步 Goal...', { sessionId: session.id });
|
||||||
broadcastSessionList();
|
broadcastSessionList();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -6857,11 +6898,7 @@ async function handleCodexAppGoalSlashCommand(ws, text, session) {
|
|||||||
if (!isCurrentCodexAppGoalCommand(session.id, activeGoalCommand)) return;
|
if (!isCurrentCodexAppGoalCommand(session.id, activeGoalCommand)) return;
|
||||||
const goal = normalizeCodexThreadGoal(response?.goal, threadId);
|
const goal = normalizeCodexThreadGoal(response?.goal, threadId);
|
||||||
const targetWs = activeGoalCommand.ws || ws;
|
const targetWs = activeGoalCommand.ws || ws;
|
||||||
wsSend(targetWs, {
|
sendGoalSystemMessage(targetWs, goal ? formatCodexGoalUsage(goal) : '用法: /goal <目标描述>', { sessionId: session.id });
|
||||||
type: 'system_message',
|
|
||||||
sessionId: session.id,
|
|
||||||
message: goal ? formatCodexGoalUsage(goal) : '用法: /goal <目标描述>',
|
|
||||||
});
|
|
||||||
sendSessionList(targetWs);
|
sendSessionList(targetWs);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -6870,11 +6907,7 @@ async function handleCodexAppGoalSlashCommand(ws, text, session) {
|
|||||||
const response = await client.request('thread/goal/clear', { threadId }, 30000);
|
const response = await client.request('thread/goal/clear', { threadId }, 30000);
|
||||||
if (!isCurrentCodexAppGoalCommand(session.id, activeGoalCommand)) return;
|
if (!isCurrentCodexAppGoalCommand(session.id, activeGoalCommand)) return;
|
||||||
const targetWs = activeGoalCommand.ws || ws;
|
const targetWs = activeGoalCommand.ws || ws;
|
||||||
wsSend(targetWs, {
|
sendGoalSystemMessage(targetWs, response?.cleared ? 'Goal cleared' : 'No goal to clear', { sessionId: session.id });
|
||||||
type: 'system_message',
|
|
||||||
sessionId: session.id,
|
|
||||||
message: response?.cleared ? 'Goal cleared' : 'No goal to clear',
|
|
||||||
});
|
|
||||||
sendSessionList(targetWs);
|
sendSessionList(targetWs);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -6887,18 +6920,14 @@ async function handleCodexAppGoalSlashCommand(ws, text, session) {
|
|||||||
if (!isCurrentCodexAppGoalCommand(session.id, activeGoalCommand)) return;
|
if (!isCurrentCodexAppGoalCommand(session.id, activeGoalCommand)) return;
|
||||||
const goal = normalizeCodexThreadGoal(response?.goal, threadId);
|
const goal = normalizeCodexThreadGoal(response?.goal, threadId);
|
||||||
const targetWs = activeGoalCommand.ws || ws;
|
const targetWs = activeGoalCommand.ws || ws;
|
||||||
wsSend(targetWs, {
|
sendGoalSystemMessage(targetWs, goal ? formatCodexGoalUsage(goal) : 'Goal updated', { sessionId: session.id });
|
||||||
type: 'system_message',
|
|
||||||
sessionId: session.id,
|
|
||||||
message: goal ? formatCodexGoalUsage(goal) : 'Goal updated',
|
|
||||||
});
|
|
||||||
sendSessionList(targetWs);
|
sendSessionList(targetWs);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = isCodexGoalUnsupportedError(err)
|
const message = isCodexGoalUnsupportedError(err)
|
||||||
? '当前 Codex app-server 不支持 /goal,请升级 Codex 或启用 goals feature。'
|
? '当前 Codex app-server 不支持 /goal,请升级 Codex 或启用 goals feature。'
|
||||||
: `Goal failed: ${err?.message || err}`;
|
: `Goal failed: ${err?.message || err}`;
|
||||||
if (isCurrentCodexAppGoalCommand(session.id, activeGoalCommand)) {
|
if (isCurrentCodexAppGoalCommand(session.id, activeGoalCommand)) {
|
||||||
wsSend(activeGoalCommand.ws || ws, { type: 'system_message', sessionId: session.id, message });
|
sendGoalSystemMessage(activeGoalCommand.ws || ws, message, { sessionId: session.id }, { preserveComposerDraft: true });
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
finishCodexAppGoalCommand(session.id, activeGoalCommand);
|
finishCodexAppGoalCommand(session.id, activeGoalCommand);
|
||||||
@@ -6906,19 +6935,31 @@ async function handleCodexAppGoalSlashCommand(ws, text, session) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// === Slash Command Handler ===
|
// === Slash Command Handler ===
|
||||||
function handleSlashCommand(ws, text, sessionId, fallbackAgent) {
|
function handleSlashCommand(ws, text, sessionId, fallbackAgent, source = {}) {
|
||||||
const parts = text.split(/\s+/);
|
const parts = text.split(/\s+/);
|
||||||
const cmd = parts[0].toLowerCase();
|
const cmd = parts[0].toLowerCase();
|
||||||
let session = sessionId ? loadSession(sessionId) : null;
|
let session = sessionId ? loadSession(sessionId) : null;
|
||||||
const agent = session ? getSessionAgent(session) : normalizeAgent(fallbackAgent);
|
const agent = session ? getSessionAgent(session) : normalizeAgent(fallbackAgent);
|
||||||
const codexLikeAgent = agent === 'codex' || agent === 'codexapp';
|
const codexLikeAgent = agent === 'codex' || agent === 'codexapp';
|
||||||
|
const responseSessionId = session?.id || sessionId || undefined;
|
||||||
|
const sendSlashResponse = (payload, options = {}) => {
|
||||||
|
const base = {
|
||||||
|
...(responseSessionId && payload.sessionId === undefined ? { sessionId: responseSessionId } : {}),
|
||||||
|
...payload,
|
||||||
|
...(options.preserveComposerDraft ? { preserveComposerDraft: true } : {}),
|
||||||
|
};
|
||||||
|
wsSend(ws, attachClientRequestId(base, source));
|
||||||
|
};
|
||||||
|
const sendSlashSystemMessage = (message, extra = {}, options = {}) => {
|
||||||
|
sendSlashResponse({ type: 'system_message', message, ...extra }, options);
|
||||||
|
};
|
||||||
|
|
||||||
if (session && isCodexAppSession(session) && activeCodexAppTurns.has(sessionId)) {
|
if (session && isCodexAppSession(session) && activeCodexAppTurns.has(sessionId)) {
|
||||||
wsSend(ws, { type: 'system_message', message: 'Codex App 运行中暂不支持 slash 指令,请等待完成或点击停止。' });
|
sendSlashSystemMessage('Codex App 运行中暂不支持 slash 指令,请等待完成或点击停止。', {}, { preserveComposerDraft: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (session && isCodexAppSession(session) && activeCodexAppGoalCommands.has(sessionId)) {
|
if (session && isCodexAppSession(session) && activeCodexAppGoalCommands.has(sessionId)) {
|
||||||
wsSend(ws, { type: 'system_message', sessionId, message: 'Codex App Goal 正在同步,请稍候。' });
|
sendSlashSystemMessage('Codex App Goal 正在同步,请稍候。', { sessionId }, { preserveComposerDraft: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6937,21 +6978,22 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent) {
|
|||||||
clearRuntimeSessionId(session);
|
clearRuntimeSessionId(session);
|
||||||
session.updated = new Date().toISOString();
|
session.updated = new Date().toISOString();
|
||||||
saveSession(session);
|
saveSession(session);
|
||||||
wsSend(ws, attachClientRequestId({
|
sendSlashResponse({
|
||||||
type: 'session_info',
|
type: 'session_info',
|
||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
messages: [],
|
messages: [],
|
||||||
title: session.title,
|
title: session.title,
|
||||||
pinnedAt: session.pinnedAt || null,
|
pinnedAt: session.pinnedAt || null,
|
||||||
|
...publicTitleMetadata(session),
|
||||||
mode: session.permissionMode || 'yolo',
|
mode: session.permissionMode || 'yolo',
|
||||||
model: sessionModelLabel(session),
|
model: sessionModelLabel(session),
|
||||||
agent: getSessionAgent(session),
|
agent: getSessionAgent(session),
|
||||||
cwd: session.cwd || null,
|
cwd: session.cwd || null,
|
||||||
totalCost: session.totalCost || 0,
|
totalCost: session.totalCost || 0,
|
||||||
totalUsage: session.totalUsage || null,
|
totalUsage: session.totalUsage || null,
|
||||||
}, { sessionId }));
|
});
|
||||||
}
|
}
|
||||||
wsSend(ws, { type: 'system_message', message: '会话已清除,上下文已重置。' });
|
sendSlashSystemMessage('会话已清除,上下文已重置。');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6960,7 +7002,7 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent) {
|
|||||||
if (codexLikeAgent) {
|
if (codexLikeAgent) {
|
||||||
if (!modelInput) {
|
if (!modelInput) {
|
||||||
const current = session?.model || getDefaultCodexModel();
|
const current = session?.model || getDefaultCodexModel();
|
||||||
wsSend(ws, { type: 'system_message', message: `当前 ${agent === 'codexapp' ? 'Codex App' : 'Codex'} 模型: ${current}\n用法: /model <模型名>` });
|
sendSlashSystemMessage(`当前 ${agent === 'codexapp' ? 'Codex App' : 'Codex'} 模型: ${current}\n用法: /model <模型名>`);
|
||||||
} else {
|
} else {
|
||||||
if (session) {
|
if (session) {
|
||||||
session.model = modelInput;
|
session.model = modelInput;
|
||||||
@@ -6968,15 +7010,15 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent) {
|
|||||||
saveSession(session);
|
saveSession(session);
|
||||||
}
|
}
|
||||||
wsSend(ws, { type: 'model_changed', model: modelInput });
|
wsSend(ws, { type: 'model_changed', model: modelInput });
|
||||||
wsSend(ws, { type: 'system_message', message: `${agent === 'codexapp' ? 'Codex App' : 'Codex'} 模型已切换为: ${modelInput}` });
|
sendSlashSystemMessage(`${agent === 'codexapp' ? 'Codex App' : 'Codex'} 模型已切换为: ${modelInput}`);
|
||||||
}
|
}
|
||||||
} else if (!modelInput) {
|
} else if (!modelInput) {
|
||||||
const current = session?.model ? modelShortName(session.model) || session.model : 'opus (默认)';
|
const current = session?.model ? modelShortName(session.model) || session.model : 'opus (默认)';
|
||||||
wsSend(ws, { type: 'system_message', message: `当前模型: ${current}\n可选: opus, sonnet, haiku` });
|
sendSlashSystemMessage(`当前模型: ${current}\n可选: opus, sonnet, haiku`);
|
||||||
} else {
|
} else {
|
||||||
const modelKey = modelInput.toLowerCase();
|
const modelKey = modelInput.toLowerCase();
|
||||||
if (!MODEL_MAP[modelKey]) {
|
if (!MODEL_MAP[modelKey]) {
|
||||||
wsSend(ws, { type: 'system_message', message: `无效模型: ${modelInput}\n可选: opus, sonnet, haiku` });
|
sendSlashSystemMessage(`无效模型: ${modelInput}\n可选: opus, sonnet, haiku`, {}, { preserveComposerDraft: true });
|
||||||
} else {
|
} else {
|
||||||
const model = MODEL_MAP[modelKey];
|
const model = MODEL_MAP[modelKey];
|
||||||
if (session) {
|
if (session) {
|
||||||
@@ -6985,7 +7027,7 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent) {
|
|||||||
saveSession(session);
|
saveSession(session);
|
||||||
}
|
}
|
||||||
wsSend(ws, { type: 'model_changed', model: modelKey });
|
wsSend(ws, { type: 'model_changed', model: modelKey });
|
||||||
wsSend(ws, { type: 'system_message', message: `模型已切换为: ${modelKey}` });
|
sendSlashSystemMessage(`模型已切换为: ${modelKey}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -6994,96 +7036,104 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent) {
|
|||||||
case '/cost': {
|
case '/cost': {
|
||||||
if (codexLikeAgent) {
|
if (codexLikeAgent) {
|
||||||
const usage = session?.totalUsage || { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 };
|
const usage = session?.totalUsage || { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 };
|
||||||
wsSend(ws, {
|
sendSlashResponse({
|
||||||
type: 'system_message',
|
type: 'system_message',
|
||||||
message: `当前会话累计 Token: 输入 ${usage.inputTokens},缓存 ${usage.cachedInputTokens},输出 ${usage.outputTokens}`,
|
message: `当前会话累计 Token: 输入 ${usage.inputTokens},缓存 ${usage.cachedInputTokens},输出 ${usage.outputTokens}`,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const cost = session?.totalCost || 0;
|
const cost = session?.totalCost || 0;
|
||||||
wsSend(ws, { type: 'system_message', message: `当前会话累计费用: $${cost.toFixed(4)}` });
|
sendSlashSystemMessage(`当前会话累计费用: $${cost.toFixed(4)}`);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case '/goal': {
|
case '/goal': {
|
||||||
handleCodexAppGoalSlashCommand(ws, text, session).catch((err) => {
|
handleCodexAppGoalSlashCommand(ws, text, session, source).catch((err) => {
|
||||||
wsSend(ws, {
|
sendSlashResponse({
|
||||||
type: 'system_message',
|
type: 'system_message',
|
||||||
sessionId: session?.id,
|
sessionId: session?.id,
|
||||||
message: `Goal failed: ${err?.message || err}`,
|
message: `Goal failed: ${err?.message || err}`,
|
||||||
});
|
}, { preserveComposerDraft: true });
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case '/compact': {
|
case '/compact': {
|
||||||
if (!sessionId || !session) {
|
if (!sessionId || !session) {
|
||||||
wsSend(ws, { type: 'system_message', message: '当前没有可压缩的会话。请先进入一个已进行过对话的会话后再执行 /compact。' });
|
sendSlashSystemMessage('当前没有可压缩的会话。请先进入一个已进行过对话的会话后再执行 /compact。', {}, { preserveComposerDraft: true });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (isSessionRunning(sessionId)) {
|
if (isSessionRunning(sessionId)) {
|
||||||
wsSend(ws, { type: 'system_message', message: '当前会话正在处理中,请先等待完成或点击停止,再执行 /compact。' });
|
sendSlashSystemMessage('当前会话正在处理中,请先等待完成或点击停止,再执行 /compact。', {}, { preserveComposerDraft: true });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (isCodexAppSession(session)) {
|
if (isCodexAppSession(session)) {
|
||||||
wsSend(ws, { type: 'system_message', message: 'Codex App 模式暂不支持 /compact,请切换到旧 Codex 模式或等待后续接入。' });
|
sendSlashSystemMessage('Codex App 模式暂不支持 /compact,请切换到旧 Codex 模式或等待后续接入。', {}, { preserveComposerDraft: true });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
const runtimeId = getRuntimeSessionId(session);
|
const runtimeId = getRuntimeSessionId(session);
|
||||||
if (!runtimeId) {
|
if (!runtimeId) {
|
||||||
wsSend(ws, {
|
sendSlashResponse({
|
||||||
type: 'system_message',
|
type: 'system_message',
|
||||||
message: agent === 'codex'
|
message: agent === 'codex'
|
||||||
? '当前会话尚未建立 Codex 上下文,暂时无需压缩。'
|
? '当前会话尚未建立 Codex 上下文,暂时无需压缩。'
|
||||||
: '当前会话尚未建立 Claude 上下文,暂时无需压缩。',
|
: '当前会话尚未建立 Claude 上下文,暂时无需压缩。',
|
||||||
});
|
}, { preserveComposerDraft: true });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
wsSend(ws, { type: 'system_message', message: compactStartMessage(agent) });
|
sendSlashSystemMessage(compactStartMessage(agent));
|
||||||
pendingSlashCommands.set(session.id, { kind: 'compact' });
|
pendingSlashCommands.set(session.id, { kind: 'compact' });
|
||||||
handleMessage(ws, { text: '/compact', sessionId: session.id, mode: session.permissionMode || 'yolo' }, { hideInHistory: true });
|
handleMessage(ws, {
|
||||||
|
text: '/compact',
|
||||||
|
sessionId: session.id,
|
||||||
|
mode: session.permissionMode || 'yolo',
|
||||||
|
requestId: source?.requestId,
|
||||||
|
preserveComposerDraft: true,
|
||||||
|
}, { hideInHistory: true });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case '/init': {
|
case '/init': {
|
||||||
if (!sessionId || !session) {
|
if (!sessionId || !session) {
|
||||||
wsSend(ws, { type: 'system_message', message: '请先进入一个会话后再执行 /init。' });
|
sendSlashSystemMessage('请先进入一个会话后再执行 /init。', {}, { preserveComposerDraft: true });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (isSessionRunning(sessionId)) {
|
if (isSessionRunning(sessionId)) {
|
||||||
wsSend(ws, { type: 'system_message', message: '当前会话正在处理中,请先等待完成或点击停止。' });
|
sendSlashSystemMessage('当前会话正在处理中,请先等待完成或点击停止。', {}, { preserveComposerDraft: true });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
wsSend(ws, { type: 'system_message', message: initStartMessage(agent) });
|
sendSlashSystemMessage(initStartMessage(agent));
|
||||||
pendingSlashCommands.set(session.id, { kind: 'init' });
|
pendingSlashCommands.set(session.id, { kind: 'init' });
|
||||||
handleMessage(ws, {
|
handleMessage(ws, {
|
||||||
text: codexLikeAgent ? buildCodexInitPrompt(session.cwd) : '/init',
|
text: codexLikeAgent ? buildCodexInitPrompt(session.cwd) : '/init',
|
||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
mode: session.permissionMode || 'yolo',
|
mode: session.permissionMode || 'yolo',
|
||||||
|
requestId: source?.requestId,
|
||||||
|
preserveComposerDraft: true,
|
||||||
}, { hideInHistory: true });
|
}, { hideInHistory: true });
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case '/mode': {
|
case '/mode': {
|
||||||
const modeInput = parts[1];
|
const modeInput = parts[1];
|
||||||
const VALID_MODES = ['default', 'plan', 'yolo'];
|
const VALID_MODES = ['default', 'plan', 'yolo'];
|
||||||
const MODE_DESC = { default: '默认(需权限审批,受限操作)', plan: 'Plan(需确认计划后执行)', yolo: 'YOLO(跳过所有权限检查)' };
|
const MODE_DESC = { default: '默认(需权限审批,受限操作)', plan: 'Plan(需确认计划后执行)', yolo: 'YOLO(跳过所有权限检查)' };
|
||||||
if (!modeInput) {
|
if (!modeInput) {
|
||||||
const cur = session?.permissionMode || 'yolo';
|
const cur = session?.permissionMode || 'yolo';
|
||||||
wsSend(ws, { type: 'system_message', message: `当前模式: ${MODE_DESC[cur] || cur}\n可选: default, plan, yolo` });
|
sendSlashSystemMessage(`当前模式: ${MODE_DESC[cur] || cur}\n可选: default, plan, yolo`);
|
||||||
} else if (VALID_MODES.includes(modeInput.toLowerCase())) {
|
} else if (VALID_MODES.includes(modeInput.toLowerCase())) {
|
||||||
const mode = modeInput.toLowerCase();
|
const mode = modeInput.toLowerCase();
|
||||||
if (session) {
|
if (session) {
|
||||||
session.permissionMode = mode;
|
session.permissionMode = mode;
|
||||||
// Mode switching should not reset runtime context (Claude/Codex both resume).
|
// Mode switching should not reset runtime context (Claude/Codex both resume).
|
||||||
session.updated = new Date().toISOString();
|
session.updated = new Date().toISOString();
|
||||||
saveSession(session);
|
saveSession(session);
|
||||||
}
|
}
|
||||||
wsSend(ws, { type: 'system_message', message: `权限模式已切换为: ${MODE_DESC[mode]}` });
|
sendSlashSystemMessage(`权限模式已切换为: ${MODE_DESC[mode]}`);
|
||||||
wsSend(ws, { type: 'mode_changed', mode });
|
wsSend(ws, { type: 'mode_changed', mode });
|
||||||
} else {
|
} else {
|
||||||
wsSend(ws, { type: 'system_message', message: `无效模式: ${modeInput}\n可选: default, plan, yolo` });
|
sendSlashSystemMessage(`无效模式: ${modeInput}\n可选: default, plan, yolo`, {}, { preserveComposerDraft: true });
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -7094,7 +7144,7 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent) {
|
|||||||
'/mode [模式] — 查看/切换权限模式(default, plan, yolo)\n' +
|
'/mode [模式] — 查看/切换权限模式(default, plan, yolo)\n' +
|
||||||
'/cost — 查看当前会话累计统计\n' +
|
'/cost — 查看当前会话累计统计\n' +
|
||||||
'/help — 显示本帮助';
|
'/help — 显示本帮助';
|
||||||
wsSend(ws, {
|
sendSlashResponse({
|
||||||
type: 'system_message',
|
type: 'system_message',
|
||||||
message: codexLikeAgent
|
message: codexLikeAgent
|
||||||
? base + `\n/model [名称] — 查看/切换 ${agent === 'codexapp' ? 'Codex App' : 'Codex'} 模型(自由输入)${agent === 'codexapp' ? '\n/goal [目标] — 设置/查看持久目标;支持 pause/resume/clear' : ''}\n/init — 分析项目并生成/更新 AGENTS.md${agent === 'codexapp' ? '\n/compact — Codex App 模式暂不支持' : '\n/compact — 执行 Codex /compact 压缩上下文'}`
|
? base + `\n/model [名称] — 查看/切换 ${agent === 'codexapp' ? 'Codex App' : 'Codex'} 模型(自由输入)${agent === 'codexapp' ? '\n/goal [目标] — 设置/查看持久目标;支持 pause/resume/clear' : ''}\n/init — 分析项目并生成/更新 AGENTS.md${agent === 'codexapp' ? '\n/compact — Codex App 模式暂不支持' : '\n/compact — 执行 Codex /compact 压缩上下文'}`
|
||||||
@@ -7104,7 +7154,7 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
wsSend(ws, { type: 'system_message', message: `未知指令: ${cmd}\n输入 /help 查看可用指令` });
|
sendSlashSystemMessage(`未知指令: ${cmd}\n输入 /help 查看可用指令`, {}, { preserveComposerDraft: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7115,6 +7165,80 @@ function normalizeConversationTitle(title, fallback = 'New Chat') {
|
|||||||
return truncateTextValue(normalized, MCP_CONVERSATION_TITLE_MAX_CHARS, '...');
|
return truncateTextValue(normalized, MCP_CONVERSATION_TITLE_MAX_CHARS, '...');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeSetTitleInput(title) {
|
||||||
|
const normalized = String(title || '').replace(/\s+/g, ' ').trim();
|
||||||
|
return normalized ? truncateTextValue(normalized, MCP_CONVERSATION_TITLE_MAX_CHARS, '...') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionCreatedFromKind(session) {
|
||||||
|
return String(session?.createdFrom?.kind || '').trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTitleLockedByUser(session) {
|
||||||
|
return String(session?.titleSource || '').trim().toLowerCase() === 'manual';
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicTitleMetadata(session) {
|
||||||
|
return {
|
||||||
|
titleSource: String(session?.titleSource || '').trim().toLowerCase() || null,
|
||||||
|
createdFromKind: sessionCreatedFromKind(session) || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCurrentConversationTitle(args = {}, sourceSessionId = '') {
|
||||||
|
const sourceId = sanitizeId(sourceSessionId || '');
|
||||||
|
if (!sourceId) return mcpToolError('missing_source_conversation', '缺少来源对话 ID。');
|
||||||
|
|
||||||
|
const session = loadSession(sourceId);
|
||||||
|
if (!session) return mcpToolError('source_not_found', '来源对话不存在。', { sourceConversationId: sourceId });
|
||||||
|
|
||||||
|
const previousTitle = session.title || 'Untitled';
|
||||||
|
const normalizedTitle = normalizeSetTitleInput(args.title);
|
||||||
|
if (!normalizedTitle) {
|
||||||
|
return mcpToolError('invalid_title', 'title 不能为空。', {
|
||||||
|
changed: false,
|
||||||
|
ignored: false,
|
||||||
|
title: previousTitle,
|
||||||
|
previousTitle,
|
||||||
|
lockedByUser: isTitleLockedByUser(session),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTitleLockedByUser(session)) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
changed: false,
|
||||||
|
ignored: true,
|
||||||
|
reason: 'ignored because title is manually locked',
|
||||||
|
title: previousTitle,
|
||||||
|
previousTitle,
|
||||||
|
lockedByUser: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const changed = previousTitle !== normalizedTitle;
|
||||||
|
session.title = normalizedTitle;
|
||||||
|
session.titleSource = 'llm';
|
||||||
|
saveSession(session);
|
||||||
|
sendSessionEventToViewers(session.id, {
|
||||||
|
type: 'session_renamed',
|
||||||
|
sessionId: session.id,
|
||||||
|
title: session.title,
|
||||||
|
...publicTitleMetadata(session),
|
||||||
|
});
|
||||||
|
broadcastSessionList();
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
changed,
|
||||||
|
ignored: false,
|
||||||
|
reason: changed ? 'title_updated' : 'unchanged',
|
||||||
|
title: session.title,
|
||||||
|
previousTitle,
|
||||||
|
lockedByUser: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function resolveConversationAgent(rawAgent, fallbackAgent = 'claude', options = {}) {
|
function resolveConversationAgent(rawAgent, fallbackAgent = 'claude', options = {}) {
|
||||||
const value = String(rawAgent || '').trim().toLowerCase();
|
const value = String(rawAgent || '').trim().toLowerCase();
|
||||||
if (value) {
|
if (value) {
|
||||||
@@ -7223,6 +7347,7 @@ function createPersistentConversationSession(args = {}, options = {}) {
|
|||||||
const session = {
|
const session = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
title: normalizeConversationTitle(args.title),
|
title: normalizeConversationTitle(args.title),
|
||||||
|
titleSource: 'system',
|
||||||
created: now,
|
created: now,
|
||||||
updated: now,
|
updated: now,
|
||||||
pinnedAt: null,
|
pinnedAt: null,
|
||||||
@@ -7259,6 +7384,7 @@ function buildSessionInfoPayload(session) {
|
|||||||
messages,
|
messages,
|
||||||
title: session.title,
|
title: session.title,
|
||||||
pinnedAt: session.pinnedAt || null,
|
pinnedAt: session.pinnedAt || null,
|
||||||
|
...publicTitleMetadata(session),
|
||||||
mode: session.permissionMode || 'yolo',
|
mode: session.permissionMode || 'yolo',
|
||||||
model: sessionModelLabel(session),
|
model: sessionModelLabel(session),
|
||||||
agent: getSessionAgent(session),
|
agent: getSessionAgent(session),
|
||||||
@@ -7474,6 +7600,7 @@ function handleLoadSession(ws, msg) {
|
|||||||
messages: recentMessages,
|
messages: recentMessages,
|
||||||
title: refreshedSession.title,
|
title: refreshedSession.title,
|
||||||
pinnedAt: refreshedSession.pinnedAt || null,
|
pinnedAt: refreshedSession.pinnedAt || null,
|
||||||
|
...publicTitleMetadata(refreshedSession),
|
||||||
mode: refreshedSession.permissionMode || 'yolo',
|
mode: refreshedSession.permissionMode || 'yolo',
|
||||||
model: sessionModelLabel(refreshedSession),
|
model: sessionModelLabel(refreshedSession),
|
||||||
agent: getSessionAgent(refreshedSession),
|
agent: getSessionAgent(refreshedSession),
|
||||||
@@ -7641,10 +7768,11 @@ function handleRenameSession(ws, sessionId, title) {
|
|||||||
const session = loadSession(sessionId);
|
const session = loadSession(sessionId);
|
||||||
if (session) {
|
if (session) {
|
||||||
session.title = String(title).slice(0, 100);
|
session.title = String(title).slice(0, 100);
|
||||||
|
session.titleSource = 'manual';
|
||||||
session.updated = new Date().toISOString();
|
session.updated = new Date().toISOString();
|
||||||
saveSession(session);
|
saveSession(session);
|
||||||
sendSessionList(ws);
|
sendSessionList(ws);
|
||||||
wsSend(ws, { type: 'session_renamed', sessionId, title: session.title });
|
wsSend(ws, { type: 'session_renamed', sessionId, title: session.title, ...publicTitleMetadata(session) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -7791,7 +7919,13 @@ function handleMessage(ws, msg, options = {}) {
|
|||||||
const { text, sessionId, mode } = msg;
|
const { text, sessionId, mode } = msg;
|
||||||
const { hideInHistory = false } = options;
|
const { hideInHistory = false } = options;
|
||||||
const fail = (code, message) => {
|
const fail = (code, message) => {
|
||||||
wsSend(ws, { type: 'error', code, message });
|
wsSend(ws, attachClientRequestId({
|
||||||
|
type: 'error',
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
...(sessionId ? { sessionId } : {}),
|
||||||
|
...(msg.preserveComposerDraft ? { preserveComposerDraft: true } : {}),
|
||||||
|
}, msg));
|
||||||
return { ok: false, code, message };
|
return { ok: false, code, message };
|
||||||
};
|
};
|
||||||
const textValue = typeof text === 'string' ? text : '';
|
const textValue = typeof text === 'string' ? text : '';
|
||||||
@@ -7852,6 +7986,7 @@ function handleMessage(ws, msg, options = {}) {
|
|||||||
session = {
|
session = {
|
||||||
id,
|
id,
|
||||||
title: derivedTitle,
|
title: derivedTitle,
|
||||||
|
titleSource: 'derived',
|
||||||
created: new Date().toISOString(),
|
created: new Date().toISOString(),
|
||||||
updated: new Date().toISOString(),
|
updated: new Date().toISOString(),
|
||||||
pinnedAt: null,
|
pinnedAt: null,
|
||||||
@@ -7890,6 +8025,7 @@ function handleMessage(ws, msg, options = {}) {
|
|||||||
|
|
||||||
if (session.title === 'New Chat' || session.title === 'Untitled') {
|
if (session.title === 'New Chat' || session.title === 'Untitled') {
|
||||||
session.title = derivedTitle;
|
session.title = derivedTitle;
|
||||||
|
session.titleSource = 'derived';
|
||||||
}
|
}
|
||||||
|
|
||||||
let persistedUserMessage = null;
|
let persistedUserMessage = null;
|
||||||
@@ -8132,6 +8268,7 @@ const {
|
|||||||
saveSession,
|
saveSession,
|
||||||
setRuntimeSessionId,
|
setRuntimeSessionId,
|
||||||
getRuntimeSessionId,
|
getRuntimeSessionId,
|
||||||
|
titleToolInstructions: CCWEB_TITLE_TOOL_INSTRUCTIONS,
|
||||||
});
|
});
|
||||||
|
|
||||||
const codexAppRuntime = createCodexAppRuntime({
|
const codexAppRuntime = createCodexAppRuntime({
|
||||||
@@ -8575,6 +8712,23 @@ function codexAppCommunicationDynamicTools() {
|
|||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'ccweb_set_title',
|
||||||
|
namespace: 'ccweb',
|
||||||
|
description: '设置当前 cc-web 对话标题。只作用于当前来源对话;用户手动重命名后会成功返回但忽略,不再覆盖用户标题。',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
required: ['title'],
|
||||||
|
properties: {
|
||||||
|
title: {
|
||||||
|
type: 'string',
|
||||||
|
maxLength: MCP_CONVERSATION_TITLE_MAX_CHARS,
|
||||||
|
description: '新的当前对话标题。应简短概括用户主要目标。',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'ccweb_send_message',
|
name: 'ccweb_send_message',
|
||||||
namespace: 'ccweb',
|
namespace: 'ccweb',
|
||||||
@@ -8700,6 +8854,7 @@ function handleCodexAppDynamicToolCall(routed, params = {}) {
|
|||||||
if (namespace && namespace !== 'ccweb') return null;
|
if (namespace && namespace !== 'ccweb') return null;
|
||||||
if (
|
if (
|
||||||
tool !== 'ccweb_list_conversations' &&
|
tool !== 'ccweb_list_conversations' &&
|
||||||
|
tool !== 'ccweb_set_title' &&
|
||||||
tool !== 'ccweb_create_conversation' &&
|
tool !== 'ccweb_create_conversation' &&
|
||||||
tool !== 'ccweb_send_message' &&
|
tool !== 'ccweb_send_message' &&
|
||||||
tool !== 'ccweb_list_pending_replies' &&
|
tool !== 'ccweb_list_pending_replies' &&
|
||||||
@@ -10058,6 +10213,7 @@ function handleImportNativeSession(ws, msg) {
|
|||||||
messages: transportMessages,
|
messages: transportMessages,
|
||||||
title: session.title,
|
title: session.title,
|
||||||
pinnedAt: session.pinnedAt || null,
|
pinnedAt: session.pinnedAt || null,
|
||||||
|
...publicTitleMetadata(session),
|
||||||
mode: session.permissionMode,
|
mode: session.permissionMode,
|
||||||
model: sessionModelLabel(session),
|
model: sessionModelLabel(session),
|
||||||
agent: getSessionAgent(session),
|
agent: getSessionAgent(session),
|
||||||
@@ -10230,6 +10386,7 @@ function handleImportCodexSession(ws, msg) {
|
|||||||
messages: transportMessages,
|
messages: transportMessages,
|
||||||
title: session.title,
|
title: session.title,
|
||||||
pinnedAt: session.pinnedAt || null,
|
pinnedAt: session.pinnedAt || null,
|
||||||
|
...publicTitleMetadata(session),
|
||||||
mode: session.permissionMode,
|
mode: session.permissionMode,
|
||||||
model: sessionModelLabel(session),
|
model: sessionModelLabel(session),
|
||||||
agent: getSessionAgent(session),
|
agent: getSessionAgent(session),
|
||||||
|
|||||||
Reference in New Issue
Block a user