chore: rebuild CentOS7 release package
This commit is contained in:
301
server.js
301
server.js
@@ -705,6 +705,7 @@ let MODEL_MAP = {
|
||||
|
||||
const VALID_AGENTS = new Set(['claude', 'codex', 'codexapp']);
|
||||
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_PROMPT_TITLE_MAX_CHARS = 160;
|
||||
const MCP_PROMPT_DESCRIPTION_MAX_CHARS = 2000;
|
||||
@@ -718,7 +719,12 @@ const MCP_PROMPT_RESPONSE_MAX_CHARS = 20000;
|
||||
// Codex 默认模型优先读取 ~/.codex/config.toml,缺失时再回退到旧默认值。
|
||||
const FALLBACK_CODEX_MODEL = 'gpt-5.4';
|
||||
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 = [
|
||||
CCWEB_TITLE_TOOL_INSTRUCTIONS,
|
||||
'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.',
|
||||
'- 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);
|
||||
if (!Object.prototype.hasOwnProperty.call(session, 'pinnedAt')) 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, 'codexThreadId')) session.codexThreadId = 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) {
|
||||
const pattern = new RegExp(`"${key}"\\s*:\\s*(true|false)`);
|
||||
const match = pattern.exec(text);
|
||||
@@ -3695,6 +3719,8 @@ function loadSessionMetaFromFile(filePath) {
|
||||
updated: session.updated || session.created || stat.mtime.toISOString(),
|
||||
created: session.created || null,
|
||||
pinnedAt: session.pinnedAt || null,
|
||||
titleSource: session.titleSource || null,
|
||||
createdFromKind: sessionCreatedFromKind(session) || null,
|
||||
hasUnread: !!session.hasUnread,
|
||||
agent: getSessionAgent(session),
|
||||
cwd,
|
||||
@@ -3712,6 +3738,8 @@ function loadSessionMetaFromFile(filePath) {
|
||||
updated: jsonStringFieldFromPreview(preview, 'updated') || jsonStringFieldFromPreview(preview, 'updatedAt') || stat.mtime.toISOString(),
|
||||
created: jsonStringFieldFromPreview(preview, 'created') || null,
|
||||
pinnedAt: jsonStringFieldFromPreview(preview, 'pinnedAt') || null,
|
||||
titleSource: jsonStringFieldFromPreview(preview, 'titleSource') || null,
|
||||
createdFromKind: jsonNestedStringFieldFromPreview(preview, 'createdFrom', 'kind') || null,
|
||||
hasUnread: jsonBooleanFieldFromPreview(preview, 'hasUnread'),
|
||||
agent: normalizeAgent(jsonStringFieldFromPreview(preview, 'agent')),
|
||||
cwd,
|
||||
@@ -4357,6 +4385,8 @@ function sendSessionList(ws) {
|
||||
title: meta.title || 'Untitled',
|
||||
updated: meta.updated,
|
||||
pinnedAt: meta.pinnedAt || null,
|
||||
titleSource: meta.titleSource || null,
|
||||
createdFromKind: meta.createdFromKind || null,
|
||||
hasUnread: !!meta.hasUnread,
|
||||
agent: normalizeAgent(meta.agent),
|
||||
cwd: meta.cwd || '',
|
||||
@@ -5135,6 +5165,8 @@ function callInternalMcpTool(tool, args, sourceSessionId, sourceHopCount) {
|
||||
switch (tool) {
|
||||
case 'ccweb_list_conversations':
|
||||
return listConversationSummaries(args, sanitizeId(sourceSessionId || ''));
|
||||
case 'ccweb_set_title':
|
||||
return setCurrentConversationTitle(args, sourceSessionId);
|
||||
case 'ccweb_create_conversation':
|
||||
return createMcpConversation(args, sourceSessionId, sourceHopCount);
|
||||
case 'ccweb_send_message':
|
||||
@@ -6268,7 +6300,7 @@ wss.on('connection', (ws, req) => {
|
||||
switch (msg.type) {
|
||||
case 'message':
|
||||
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 {
|
||||
handleMessage(ws, msg);
|
||||
}
|
||||
@@ -6817,24 +6849,33 @@ function cancelCodexAppGoalCommand(sessionId, ws = null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handleCodexAppGoalSlashCommand(ws, text, session) {
|
||||
async function handleCodexAppGoalSlashCommand(ws, text, session, source = {}) {
|
||||
const command = parseCodexGoalCommand(text);
|
||||
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) {
|
||||
wsSend(ws, { type: 'system_message', message: '请先进入一个 Codex App 会话后再执行 /goal。' });
|
||||
sendGoalSystemMessage(ws, '请先进入一个 Codex App 会话后再执行 /goal。', {}, { preserveComposerDraft: true });
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
if (command.error) {
|
||||
wsSend(ws, { type: 'system_message', sessionId: session.id, message: command.error });
|
||||
sendGoalSystemMessage(ws, command.error, { sessionId: session.id }, { preserveComposerDraft: true });
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -6846,7 +6887,7 @@ async function handleCodexAppGoalSlashCommand(ws, text, session) {
|
||||
startedAt: new Date().toISOString(),
|
||||
};
|
||||
activeCodexAppGoalCommands.set(session.id, activeGoalCommand);
|
||||
wsSend(ws, { type: 'system_message', sessionId: session.id, message: '正在同步 Goal...' });
|
||||
sendGoalSystemMessage(ws, '正在同步 Goal...', { sessionId: session.id });
|
||||
broadcastSessionList();
|
||||
|
||||
try {
|
||||
@@ -6857,11 +6898,7 @@ async function handleCodexAppGoalSlashCommand(ws, text, session) {
|
||||
if (!isCurrentCodexAppGoalCommand(session.id, activeGoalCommand)) return;
|
||||
const goal = normalizeCodexThreadGoal(response?.goal, threadId);
|
||||
const targetWs = activeGoalCommand.ws || ws;
|
||||
wsSend(targetWs, {
|
||||
type: 'system_message',
|
||||
sessionId: session.id,
|
||||
message: goal ? formatCodexGoalUsage(goal) : '用法: /goal <目标描述>',
|
||||
});
|
||||
sendGoalSystemMessage(targetWs, goal ? formatCodexGoalUsage(goal) : '用法: /goal <目标描述>', { sessionId: session.id });
|
||||
sendSessionList(targetWs);
|
||||
return;
|
||||
}
|
||||
@@ -6870,11 +6907,7 @@ async function handleCodexAppGoalSlashCommand(ws, text, session) {
|
||||
const response = await client.request('thread/goal/clear', { threadId }, 30000);
|
||||
if (!isCurrentCodexAppGoalCommand(session.id, activeGoalCommand)) return;
|
||||
const targetWs = activeGoalCommand.ws || ws;
|
||||
wsSend(targetWs, {
|
||||
type: 'system_message',
|
||||
sessionId: session.id,
|
||||
message: response?.cleared ? 'Goal cleared' : 'No goal to clear',
|
||||
});
|
||||
sendGoalSystemMessage(targetWs, response?.cleared ? 'Goal cleared' : 'No goal to clear', { sessionId: session.id });
|
||||
sendSessionList(targetWs);
|
||||
return;
|
||||
}
|
||||
@@ -6887,18 +6920,14 @@ async function handleCodexAppGoalSlashCommand(ws, text, session) {
|
||||
if (!isCurrentCodexAppGoalCommand(session.id, activeGoalCommand)) return;
|
||||
const goal = normalizeCodexThreadGoal(response?.goal, threadId);
|
||||
const targetWs = activeGoalCommand.ws || ws;
|
||||
wsSend(targetWs, {
|
||||
type: 'system_message',
|
||||
sessionId: session.id,
|
||||
message: goal ? formatCodexGoalUsage(goal) : 'Goal updated',
|
||||
});
|
||||
sendGoalSystemMessage(targetWs, goal ? formatCodexGoalUsage(goal) : 'Goal updated', { sessionId: session.id });
|
||||
sendSessionList(targetWs);
|
||||
} catch (err) {
|
||||
const message = isCodexGoalUnsupportedError(err)
|
||||
? '当前 Codex app-server 不支持 /goal,请升级 Codex 或启用 goals feature。'
|
||||
: `Goal failed: ${err?.message || err}`;
|
||||
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 {
|
||||
finishCodexAppGoalCommand(session.id, activeGoalCommand);
|
||||
@@ -6906,19 +6935,31 @@ async function handleCodexAppGoalSlashCommand(ws, text, session) {
|
||||
}
|
||||
|
||||
// === Slash Command Handler ===
|
||||
function handleSlashCommand(ws, text, sessionId, fallbackAgent) {
|
||||
function handleSlashCommand(ws, text, sessionId, fallbackAgent, source = {}) {
|
||||
const parts = text.split(/\s+/);
|
||||
const cmd = parts[0].toLowerCase();
|
||||
let session = sessionId ? loadSession(sessionId) : null;
|
||||
const agent = session ? getSessionAgent(session) : normalizeAgent(fallbackAgent);
|
||||
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)) {
|
||||
wsSend(ws, { type: 'system_message', message: 'Codex App 运行中暂不支持 slash 指令,请等待完成或点击停止。' });
|
||||
sendSlashSystemMessage('Codex App 运行中暂不支持 slash 指令,请等待完成或点击停止。', {}, { preserveComposerDraft: true });
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -6937,21 +6978,22 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent) {
|
||||
clearRuntimeSessionId(session);
|
||||
session.updated = new Date().toISOString();
|
||||
saveSession(session);
|
||||
wsSend(ws, attachClientRequestId({
|
||||
sendSlashResponse({
|
||||
type: 'session_info',
|
||||
sessionId: session.id,
|
||||
messages: [],
|
||||
title: session.title,
|
||||
pinnedAt: session.pinnedAt || null,
|
||||
...publicTitleMetadata(session),
|
||||
mode: session.permissionMode || 'yolo',
|
||||
model: sessionModelLabel(session),
|
||||
agent: getSessionAgent(session),
|
||||
cwd: session.cwd || null,
|
||||
totalCost: session.totalCost || 0,
|
||||
totalUsage: session.totalUsage || null,
|
||||
}, { sessionId }));
|
||||
});
|
||||
}
|
||||
wsSend(ws, { type: 'system_message', message: '会话已清除,上下文已重置。' });
|
||||
sendSlashSystemMessage('会话已清除,上下文已重置。');
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -6960,7 +7002,7 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent) {
|
||||
if (codexLikeAgent) {
|
||||
if (!modelInput) {
|
||||
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 {
|
||||
if (session) {
|
||||
session.model = modelInput;
|
||||
@@ -6968,15 +7010,15 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent) {
|
||||
saveSession(session);
|
||||
}
|
||||
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) {
|
||||
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 {
|
||||
const modelKey = modelInput.toLowerCase();
|
||||
if (!MODEL_MAP[modelKey]) {
|
||||
wsSend(ws, { type: 'system_message', message: `无效模型: ${modelInput}\n可选: opus, sonnet, haiku` });
|
||||
sendSlashSystemMessage(`无效模型: ${modelInput}\n可选: opus, sonnet, haiku`, {}, { preserveComposerDraft: true });
|
||||
} else {
|
||||
const model = MODEL_MAP[modelKey];
|
||||
if (session) {
|
||||
@@ -6985,7 +7027,7 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent) {
|
||||
saveSession(session);
|
||||
}
|
||||
wsSend(ws, { type: 'model_changed', model: modelKey });
|
||||
wsSend(ws, { type: 'system_message', message: `模型已切换为: ${modelKey}` });
|
||||
sendSlashSystemMessage(`模型已切换为: ${modelKey}`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -6994,96 +7036,104 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent) {
|
||||
case '/cost': {
|
||||
if (codexLikeAgent) {
|
||||
const usage = session?.totalUsage || { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 };
|
||||
wsSend(ws, {
|
||||
sendSlashResponse({
|
||||
type: 'system_message',
|
||||
message: `当前会话累计 Token: 输入 ${usage.inputTokens},缓存 ${usage.cachedInputTokens},输出 ${usage.outputTokens}`,
|
||||
});
|
||||
} else {
|
||||
const cost = session?.totalCost || 0;
|
||||
wsSend(ws, { type: 'system_message', message: `当前会话累计费用: $${cost.toFixed(4)}` });
|
||||
sendSlashSystemMessage(`当前会话累计费用: $${cost.toFixed(4)}`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case '/goal': {
|
||||
handleCodexAppGoalSlashCommand(ws, text, session).catch((err) => {
|
||||
wsSend(ws, {
|
||||
handleCodexAppGoalSlashCommand(ws, text, session, source).catch((err) => {
|
||||
sendSlashResponse({
|
||||
type: 'system_message',
|
||||
sessionId: session?.id,
|
||||
message: `Goal failed: ${err?.message || err}`,
|
||||
});
|
||||
}, { preserveComposerDraft: true });
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case '/compact': {
|
||||
if (!sessionId || !session) {
|
||||
wsSend(ws, { type: 'system_message', message: '当前没有可压缩的会话。请先进入一个已进行过对话的会话后再执行 /compact。' });
|
||||
sendSlashSystemMessage('当前没有可压缩的会话。请先进入一个已进行过对话的会话后再执行 /compact。', {}, { preserveComposerDraft: true });
|
||||
break;
|
||||
}
|
||||
if (isSessionRunning(sessionId)) {
|
||||
wsSend(ws, { type: 'system_message', message: '当前会话正在处理中,请先等待完成或点击停止,再执行 /compact。' });
|
||||
sendSlashSystemMessage('当前会话正在处理中,请先等待完成或点击停止,再执行 /compact。', {}, { preserveComposerDraft: true });
|
||||
break;
|
||||
}
|
||||
if (isCodexAppSession(session)) {
|
||||
wsSend(ws, { type: 'system_message', message: 'Codex App 模式暂不支持 /compact,请切换到旧 Codex 模式或等待后续接入。' });
|
||||
sendSlashSystemMessage('Codex App 模式暂不支持 /compact,请切换到旧 Codex 模式或等待后续接入。', {}, { preserveComposerDraft: true });
|
||||
break;
|
||||
}
|
||||
const runtimeId = getRuntimeSessionId(session);
|
||||
if (!runtimeId) {
|
||||
wsSend(ws, {
|
||||
sendSlashResponse({
|
||||
type: 'system_message',
|
||||
message: agent === 'codex'
|
||||
? '当前会话尚未建立 Codex 上下文,暂时无需压缩。'
|
||||
: '当前会话尚未建立 Claude 上下文,暂时无需压缩。',
|
||||
});
|
||||
}, { preserveComposerDraft: true });
|
||||
break;
|
||||
}
|
||||
|
||||
wsSend(ws, { type: 'system_message', message: compactStartMessage(agent) });
|
||||
sendSlashSystemMessage(compactStartMessage(agent));
|
||||
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;
|
||||
}
|
||||
|
||||
case '/init': {
|
||||
if (!sessionId || !session) {
|
||||
wsSend(ws, { type: 'system_message', message: '请先进入一个会话后再执行 /init。' });
|
||||
sendSlashSystemMessage('请先进入一个会话后再执行 /init。', {}, { preserveComposerDraft: true });
|
||||
break;
|
||||
}
|
||||
if (isSessionRunning(sessionId)) {
|
||||
wsSend(ws, { type: 'system_message', message: '当前会话正在处理中,请先等待完成或点击停止。' });
|
||||
sendSlashSystemMessage('当前会话正在处理中,请先等待完成或点击停止。', {}, { preserveComposerDraft: true });
|
||||
break;
|
||||
}
|
||||
wsSend(ws, { type: 'system_message', message: initStartMessage(agent) });
|
||||
sendSlashSystemMessage(initStartMessage(agent));
|
||||
pendingSlashCommands.set(session.id, { kind: 'init' });
|
||||
handleMessage(ws, {
|
||||
text: codexLikeAgent ? buildCodexInitPrompt(session.cwd) : '/init',
|
||||
sessionId: session.id,
|
||||
mode: session.permissionMode || 'yolo',
|
||||
requestId: source?.requestId,
|
||||
preserveComposerDraft: true,
|
||||
}, { hideInHistory: true });
|
||||
break;
|
||||
}
|
||||
|
||||
case '/mode': {
|
||||
const modeInput = parts[1];
|
||||
const VALID_MODES = ['default', 'plan', 'yolo'];
|
||||
const MODE_DESC = { default: '默认(需权限审批,受限操作)', plan: 'Plan(需确认计划后执行)', yolo: 'YOLO(跳过所有权限检查)' };
|
||||
if (!modeInput) {
|
||||
const cur = session?.permissionMode || 'yolo';
|
||||
wsSend(ws, { type: 'system_message', message: `当前模式: ${MODE_DESC[cur] || cur}\n可选: default, plan, yolo` });
|
||||
} else if (VALID_MODES.includes(modeInput.toLowerCase())) {
|
||||
const mode = modeInput.toLowerCase();
|
||||
if (session) {
|
||||
session.permissionMode = mode;
|
||||
// Mode switching should not reset runtime context (Claude/Codex both resume).
|
||||
session.updated = new Date().toISOString();
|
||||
saveSession(session);
|
||||
}
|
||||
wsSend(ws, { type: 'system_message', message: `权限模式已切换为: ${MODE_DESC[mode]}` });
|
||||
wsSend(ws, { type: 'mode_changed', mode });
|
||||
} else {
|
||||
wsSend(ws, { type: 'system_message', message: `无效模式: ${modeInput}\n可选: default, plan, yolo` });
|
||||
case '/mode': {
|
||||
const modeInput = parts[1];
|
||||
const VALID_MODES = ['default', 'plan', 'yolo'];
|
||||
const MODE_DESC = { default: '默认(需权限审批,受限操作)', plan: 'Plan(需确认计划后执行)', yolo: 'YOLO(跳过所有权限检查)' };
|
||||
if (!modeInput) {
|
||||
const cur = session?.permissionMode || 'yolo';
|
||||
sendSlashSystemMessage(`当前模式: ${MODE_DESC[cur] || cur}\n可选: default, plan, yolo`);
|
||||
} else if (VALID_MODES.includes(modeInput.toLowerCase())) {
|
||||
const mode = modeInput.toLowerCase();
|
||||
if (session) {
|
||||
session.permissionMode = mode;
|
||||
// Mode switching should not reset runtime context (Claude/Codex both resume).
|
||||
session.updated = new Date().toISOString();
|
||||
saveSession(session);
|
||||
}
|
||||
sendSlashSystemMessage(`权限模式已切换为: ${MODE_DESC[mode]}`);
|
||||
wsSend(ws, { type: 'mode_changed', mode });
|
||||
} else {
|
||||
sendSlashSystemMessage(`无效模式: ${modeInput}\n可选: default, plan, yolo`, {}, { preserveComposerDraft: true });
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -7094,7 +7144,7 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent) {
|
||||
'/mode [模式] — 查看/切换权限模式(default, plan, yolo)\n' +
|
||||
'/cost — 查看当前会话累计统计\n' +
|
||||
'/help — 显示本帮助';
|
||||
wsSend(ws, {
|
||||
sendSlashResponse({
|
||||
type: 'system_message',
|
||||
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 压缩上下文'}`
|
||||
@@ -7104,7 +7154,7 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent) {
|
||||
}
|
||||
|
||||
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, '...');
|
||||
}
|
||||
|
||||
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 = {}) {
|
||||
const value = String(rawAgent || '').trim().toLowerCase();
|
||||
if (value) {
|
||||
@@ -7223,6 +7347,7 @@ function createPersistentConversationSession(args = {}, options = {}) {
|
||||
const session = {
|
||||
id: crypto.randomUUID(),
|
||||
title: normalizeConversationTitle(args.title),
|
||||
titleSource: 'system',
|
||||
created: now,
|
||||
updated: now,
|
||||
pinnedAt: null,
|
||||
@@ -7259,6 +7384,7 @@ function buildSessionInfoPayload(session) {
|
||||
messages,
|
||||
title: session.title,
|
||||
pinnedAt: session.pinnedAt || null,
|
||||
...publicTitleMetadata(session),
|
||||
mode: session.permissionMode || 'yolo',
|
||||
model: sessionModelLabel(session),
|
||||
agent: getSessionAgent(session),
|
||||
@@ -7474,6 +7600,7 @@ function handleLoadSession(ws, msg) {
|
||||
messages: recentMessages,
|
||||
title: refreshedSession.title,
|
||||
pinnedAt: refreshedSession.pinnedAt || null,
|
||||
...publicTitleMetadata(refreshedSession),
|
||||
mode: refreshedSession.permissionMode || 'yolo',
|
||||
model: sessionModelLabel(refreshedSession),
|
||||
agent: getSessionAgent(refreshedSession),
|
||||
@@ -7641,10 +7768,11 @@ function handleRenameSession(ws, sessionId, title) {
|
||||
const session = loadSession(sessionId);
|
||||
if (session) {
|
||||
session.title = String(title).slice(0, 100);
|
||||
session.titleSource = 'manual';
|
||||
session.updated = new Date().toISOString();
|
||||
saveSession(session);
|
||||
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 { hideInHistory = false } = options;
|
||||
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 };
|
||||
};
|
||||
const textValue = typeof text === 'string' ? text : '';
|
||||
@@ -7852,6 +7986,7 @@ function handleMessage(ws, msg, options = {}) {
|
||||
session = {
|
||||
id,
|
||||
title: derivedTitle,
|
||||
titleSource: 'derived',
|
||||
created: new Date().toISOString(),
|
||||
updated: new Date().toISOString(),
|
||||
pinnedAt: null,
|
||||
@@ -7890,6 +8025,7 @@ function handleMessage(ws, msg, options = {}) {
|
||||
|
||||
if (session.title === 'New Chat' || session.title === 'Untitled') {
|
||||
session.title = derivedTitle;
|
||||
session.titleSource = 'derived';
|
||||
}
|
||||
|
||||
let persistedUserMessage = null;
|
||||
@@ -8132,6 +8268,7 @@ const {
|
||||
saveSession,
|
||||
setRuntimeSessionId,
|
||||
getRuntimeSessionId,
|
||||
titleToolInstructions: CCWEB_TITLE_TOOL_INSTRUCTIONS,
|
||||
});
|
||||
|
||||
const codexAppRuntime = createCodexAppRuntime({
|
||||
@@ -8575,6 +8712,23 @@ function codexAppCommunicationDynamicTools() {
|
||||
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',
|
||||
namespace: 'ccweb',
|
||||
@@ -8700,6 +8854,7 @@ function handleCodexAppDynamicToolCall(routed, params = {}) {
|
||||
if (namespace && namespace !== 'ccweb') return null;
|
||||
if (
|
||||
tool !== 'ccweb_list_conversations' &&
|
||||
tool !== 'ccweb_set_title' &&
|
||||
tool !== 'ccweb_create_conversation' &&
|
||||
tool !== 'ccweb_send_message' &&
|
||||
tool !== 'ccweb_list_pending_replies' &&
|
||||
@@ -10058,6 +10213,7 @@ function handleImportNativeSession(ws, msg) {
|
||||
messages: transportMessages,
|
||||
title: session.title,
|
||||
pinnedAt: session.pinnedAt || null,
|
||||
...publicTitleMetadata(session),
|
||||
mode: session.permissionMode,
|
||||
model: sessionModelLabel(session),
|
||||
agent: getSessionAgent(session),
|
||||
@@ -10230,6 +10386,7 @@ function handleImportCodexSession(ws, msg) {
|
||||
messages: transportMessages,
|
||||
title: session.title,
|
||||
pinnedAt: session.pinnedAt || null,
|
||||
...publicTitleMetadata(session),
|
||||
mode: session.permissionMode,
|
||||
model: sessionModelLabel(session),
|
||||
agent: getSessionAgent(session),
|
||||
|
||||
Reference in New Issue
Block a user