feat: support custom instance icons and refresh release
This commit is contained in:
240
server.js
240
server.js
@@ -139,6 +139,7 @@ const LOGS_DIR = process.env.CC_WEB_LOGS_DIR || path.join(APP_DIR, 'logs');
|
||||
const ATTACHMENTS_DIR = path.join(SESSIONS_DIR, '_attachments');
|
||||
const ATTACHMENT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024;
|
||||
const MAX_INSTANCE_ICON_SIZE = 4 * 1024 * 1024;
|
||||
const FILE_BROWSER_MAX_LIST_ENTRIES = 400;
|
||||
const FILE_BROWSER_MAX_PREVIEW_BYTES = 200 * 1024;
|
||||
// MCP 工具可能来自多个 server;20 条会在空查询时静默截断大部分工具。
|
||||
@@ -283,6 +284,9 @@ const BANNED_IPS_PATH = path.join(CONFIG_DIR, 'banned_ips.json');
|
||||
const CROSS_CONVERSATION_REPLIES_PATH = path.join(CONFIG_DIR, 'cross-conversation-replies.json');
|
||||
const TASK_BOARD_STATUS_CONFIG_PATH = path.join(CONFIG_DIR, 'task-board-statuses.json');
|
||||
const GITEA_WORKFLOW_MANAGEMENT_STATE_PATH = path.join(CONFIG_DIR, 'gitea-workflow-management.json');
|
||||
const INSTANCE_ICON_PATH = path.join(CONFIG_DIR, 'instance-icon.png');
|
||||
const INSTANCE_ICON_TMP_PATH = path.join(CONFIG_DIR, '.instance-icon.png.tmp');
|
||||
const DEFAULT_INSTANCE_ICON_PATH = path.join(PUBLIC_DIR, 'icon-192.png');
|
||||
|
||||
fs.mkdirSync(SESSIONS_DIR, { recursive: true });
|
||||
fs.mkdirSync(LOGS_DIR, { recursive: true });
|
||||
@@ -2345,6 +2349,156 @@ function jsonResponse(res, statusCode, payload) {
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function validateInstanceIconPng(buffer) {
|
||||
const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
if (!Buffer.isBuffer(buffer) || buffer.length < 33 || !buffer.subarray(0, 8).equals(signature)) {
|
||||
return { valid: false, message: '图标内容不是有效的 PNG 图片' };
|
||||
}
|
||||
if (buffer.readUInt32BE(8) !== 13 || buffer.toString('ascii', 12, 16) !== 'IHDR') {
|
||||
return { valid: false, message: '图标缺少有效的 PNG IHDR 信息' };
|
||||
}
|
||||
const width = buffer.readUInt32BE(16);
|
||||
const height = buffer.readUInt32BE(20);
|
||||
if (width !== 512 || height !== 512) {
|
||||
return { valid: false, message: '图标必须是 512×512 像素' };
|
||||
}
|
||||
return { valid: true, width, height };
|
||||
}
|
||||
|
||||
function readCustomInstanceIcon() {
|
||||
try {
|
||||
const buffer = fs.readFileSync(INSTANCE_ICON_PATH);
|
||||
if (!validateInstanceIconPng(buffer).valid) return null;
|
||||
const stat = fs.statSync(INSTANCE_ICON_PATH);
|
||||
return {
|
||||
buffer,
|
||||
version: crypto.createHash('sha256').update(buffer).digest('hex').slice(0, 16),
|
||||
updatedAt: stat.mtime.toISOString(),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function currentInstanceIconConfig() {
|
||||
const customIcon = readCustomInstanceIcon();
|
||||
const version = customIcon?.version || 'default';
|
||||
return {
|
||||
custom: !!customIcon,
|
||||
version,
|
||||
updatedAt: customIcon?.updatedAt || null,
|
||||
url: `/api/instance-icon?v=${encodeURIComponent(version)}`,
|
||||
};
|
||||
}
|
||||
|
||||
function sendInstanceIconConfig(res) {
|
||||
return jsonResponse(res, 200, { ok: true, ...currentInstanceIconConfig() });
|
||||
}
|
||||
|
||||
function sendInstanceIconFile(req, res) {
|
||||
const config = currentInstanceIconConfig();
|
||||
let buffer;
|
||||
try {
|
||||
buffer = fs.readFileSync(config.custom ? INSTANCE_ICON_PATH : DEFAULT_INSTANCE_ICON_PATH);
|
||||
} catch (err) {
|
||||
return jsonResponse(res, 500, { ok: false, message: `读取实例图标失败: ${err.message}` });
|
||||
}
|
||||
const responseVersion = config.custom
|
||||
? config.version
|
||||
: crypto.createHash('sha256').update(buffer).digest('hex').slice(0, 16);
|
||||
const etag = `"${responseVersion}"`;
|
||||
if (String(req.headers['if-none-match'] || '') === etag) {
|
||||
res.writeHead(304, {
|
||||
ETag: etag,
|
||||
'Cache-Control': 'no-cache, max-age=0',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
});
|
||||
return res.end();
|
||||
}
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'image/png',
|
||||
'Content-Length': buffer.length,
|
||||
'Cache-Control': 'no-cache, max-age=0',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
ETag: etag,
|
||||
});
|
||||
return res.end(buffer);
|
||||
}
|
||||
|
||||
function sendInstanceIconManifest(res) {
|
||||
const config = currentInstanceIconConfig();
|
||||
const icons = config.custom
|
||||
? [{ src: config.url, sizes: '512x512', type: 'image/png' }]
|
||||
: [
|
||||
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
|
||||
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png' },
|
||||
];
|
||||
const payload = {
|
||||
name: 'CC-Web',
|
||||
short_name: 'CC-Web',
|
||||
start_url: '.',
|
||||
display: 'standalone',
|
||||
background_color: '#020c16',
|
||||
theme_color: '#020c16',
|
||||
icons,
|
||||
};
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'application/manifest+json; charset=utf-8',
|
||||
'Cache-Control': 'no-cache, max-age=0',
|
||||
});
|
||||
return res.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function saveInstanceIconUpload(req, res) {
|
||||
const mime = String(req.headers['content-type'] || '').split(';')[0].trim().toLowerCase();
|
||||
if (mime !== 'image/png') {
|
||||
return jsonResponse(res, 400, { ok: false, message: '实例图标仅接受 PNG 格式' });
|
||||
}
|
||||
const declaredSize = Number(req.headers['content-length'] || 0);
|
||||
if (Number.isFinite(declaredSize) && declaredSize > MAX_INSTANCE_ICON_SIZE) {
|
||||
req.resume();
|
||||
return jsonResponse(res, 413, { ok: false, message: '图标大小不能超过 4MB' });
|
||||
}
|
||||
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
let tooLarge = false;
|
||||
req.on('data', (chunk) => {
|
||||
total += chunk.length;
|
||||
if (total > MAX_INSTANCE_ICON_SIZE) {
|
||||
tooLarge = true;
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on('end', () => {
|
||||
if (tooLarge) {
|
||||
return jsonResponse(res, 413, { ok: false, message: '图标大小不能超过 4MB' });
|
||||
}
|
||||
const buffer = Buffer.concat(chunks);
|
||||
if (buffer.length === 0) {
|
||||
return jsonResponse(res, 400, { ok: false, message: '图标内容为空' });
|
||||
}
|
||||
const validation = validateInstanceIconPng(buffer);
|
||||
if (!validation.valid) {
|
||||
return jsonResponse(res, 400, { ok: false, message: validation.message });
|
||||
}
|
||||
try {
|
||||
fs.writeFileSync(INSTANCE_ICON_TMP_PATH, buffer, { mode: 0o600 });
|
||||
fs.renameSync(INSTANCE_ICON_TMP_PATH, INSTANCE_ICON_PATH);
|
||||
return jsonResponse(res, 200, { ok: true, ...currentInstanceIconConfig() });
|
||||
} catch (err) {
|
||||
try { fs.unlinkSync(INSTANCE_ICON_TMP_PATH); } catch {}
|
||||
return jsonResponse(res, 500, { ok: false, message: `保存实例图标失败: ${err.message}` });
|
||||
}
|
||||
});
|
||||
req.on('error', () => {
|
||||
try { fs.unlinkSync(INSTANCE_ICON_TMP_PATH); } catch {}
|
||||
if (!res.headersSent) jsonResponse(res, 500, { ok: false, message: '上传实例图标时连接中断' });
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readJsonBody(req, maxBytes = 1024 * 1024) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
@@ -6960,6 +7114,32 @@ function normalizeCrossConversationReplyMode(args = {}, options = {}) {
|
||||
return CCWEB_REPLY_MODES.ONE_WAY;
|
||||
}
|
||||
|
||||
function crossConversationReplyRequestIds(entry = {}) {
|
||||
return Array.from(new Set([
|
||||
entry.crossConversationReplyRequestId,
|
||||
...(Array.isArray(entry.crossConversationReplyRequestIds) ? entry.crossConversationReplyRequestIds : []),
|
||||
].map((value) => String(value || '').trim()).filter(Boolean)));
|
||||
}
|
||||
|
||||
function registerCrossConversationReplyRequest(entry, requestId) {
|
||||
const normalizedRequestId = String(requestId || '').trim();
|
||||
if (!entry || !normalizedRequestId) return false;
|
||||
const requestIds = crossConversationReplyRequestIds(entry);
|
||||
if (!requestIds.includes(normalizedRequestId)) requestIds.push(normalizedRequestId);
|
||||
entry.crossConversationReplyRequestId = requestIds[0] || null;
|
||||
entry.crossConversationReplyRequestIds = requestIds;
|
||||
return true;
|
||||
}
|
||||
|
||||
function unregisterCrossConversationReplyRequest(entry, requestId) {
|
||||
const normalizedRequestId = String(requestId || '').trim();
|
||||
if (!entry || !normalizedRequestId) return false;
|
||||
const requestIds = crossConversationReplyRequestIds(entry).filter((id) => id !== normalizedRequestId);
|
||||
entry.crossConversationReplyRequestId = requestIds[0] || null;
|
||||
entry.crossConversationReplyRequestIds = requestIds;
|
||||
return true;
|
||||
}
|
||||
|
||||
function sendCrossConversationMessage(args = {}, sourceSessionId = '', sourceHopCount = 0, options = {}) {
|
||||
const sourceId = sanitizeId(sourceSessionId || '');
|
||||
const targetId = sanitizeId(args.targetConversationId || args.targetSessionId || args.conversationId || '');
|
||||
@@ -7001,7 +7181,8 @@ function sendCrossConversationMessage(args = {}, sourceSessionId = '', sourceHop
|
||||
|
||||
const normalizedHopCount = Math.max(0, Number.parseInt(String(sourceHopCount || 0), 10) || 0);
|
||||
|
||||
if (isSessionRunning(targetId)) {
|
||||
const targetHasActiveCodexAppTurn = activeCodexAppTurns.has(targetId);
|
||||
if (isSessionRunning(targetId) && !targetHasActiveCodexAppTurn) {
|
||||
return mcpToolError('target_running', '目标对话正在处理中,请稍后再发送。', { targetConversationId: targetId });
|
||||
}
|
||||
|
||||
@@ -7063,7 +7244,7 @@ function sendCrossConversationMessage(args = {}, sourceSessionId = '', sourceHop
|
||||
return {
|
||||
ok: true,
|
||||
messageId,
|
||||
deliveryStatus: 'delivered',
|
||||
deliveryStatus: targetHasActiveCodexAppTurn ? 'steering' : 'delivered',
|
||||
replyMode,
|
||||
sourceConversationId: sourceId,
|
||||
targetConversationId: targetId,
|
||||
@@ -7727,7 +7908,7 @@ function shouldRetryCodexTransientFailure(entry, rawError, context = {}) {
|
||||
if (!rawError || !isCodexTransientRetryableError(rawError)) return false;
|
||||
if (getCodexRetryConfig().mode === 'off') return false;
|
||||
if (context.contextLimitExceeded || context.pendingSlash) return false;
|
||||
if (entry.crossConversationReplyRequestId) return false;
|
||||
if (crossConversationReplyRequestIds(entry).length > 0) return false;
|
||||
if ((entry.agent || 'claude') !== 'codexapp' && hasRuntimeOutput(entry)) return false;
|
||||
return !!(entry.retryRequest?.text || entry.retryRequest?.runtimeText);
|
||||
}
|
||||
@@ -7972,8 +8153,8 @@ function handleProcessComplete(sessionId, exitCode, signal) {
|
||||
outcome: completionError ? 'failed' : 'completed',
|
||||
trackingEnabled: entry.taskTrackingEnabled === true,
|
||||
});
|
||||
if (entry.crossConversationReplyRequestId) {
|
||||
completeCrossConversationReply(entry.crossConversationReplyRequestId, entry, session);
|
||||
for (const requestId of crossConversationReplyRequestIds(entry)) {
|
||||
completeCrossConversationReply(requestId, entry, session);
|
||||
}
|
||||
flushPendingCrossConversationReplies(sessionId);
|
||||
|
||||
@@ -8184,6 +8365,35 @@ function recoverProcesses() {
|
||||
const server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/instance-icon/config') {
|
||||
return sendInstanceIconConfig(res);
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/api/site.webmanifest') {
|
||||
return sendInstanceIconManifest(res);
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/instance-icon') {
|
||||
if (req.method === 'GET') return sendInstanceIconFile(req, res);
|
||||
const token = extractBearerToken(req);
|
||||
if (!token || !activeTokens.has(token)) {
|
||||
return jsonResponse(res, 401, { ok: false, message: 'Not authenticated' });
|
||||
}
|
||||
if (req.method === 'POST') return saveInstanceIconUpload(req, res);
|
||||
if (req.method === 'DELETE') {
|
||||
try {
|
||||
fs.unlinkSync(INSTANCE_ICON_PATH);
|
||||
} catch (err) {
|
||||
if (err?.code !== 'ENOENT') {
|
||||
return jsonResponse(res, 500, { ok: false, message: `恢复默认图标失败: ${err.message}` });
|
||||
}
|
||||
}
|
||||
try { fs.unlinkSync(INSTANCE_ICON_TMP_PATH); } catch {}
|
||||
return jsonResponse(res, 200, { ok: true, ...currentInstanceIconConfig() });
|
||||
}
|
||||
return jsonResponse(res, 405, { ok: false, message: 'Method not allowed' });
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/gitea-workflow/')) {
|
||||
return handleGiteaWorkflowManagementApi(req, res, url, giteaWorkflowManagement, {
|
||||
authenticate(request) {
|
||||
@@ -12966,8 +13176,8 @@ function handleCodexAppTurnComplete(sessionId, options = {}) {
|
||||
toolEvidence,
|
||||
});
|
||||
}
|
||||
if (entry.crossConversationReplyRequestId) {
|
||||
completeCrossConversationReply(entry.crossConversationReplyRequestId, entry, session);
|
||||
for (const requestId of crossConversationReplyRequestIds(entry)) {
|
||||
completeCrossConversationReply(requestId, entry, session);
|
||||
}
|
||||
if (!options.deferPendingCrossConversationFlush) {
|
||||
flushPendingCrossConversationReplies(sessionId);
|
||||
@@ -13108,6 +13318,14 @@ function handleCodexAppSteerMessage(ws, msg, options = {}) {
|
||||
const normalizedRuntimeText = runtimeTextValue.trim();
|
||||
if (!normalizedRuntimeText && resolvedAttachments.length === 0) return fail('empty_message', '运行中插入内容不能为空。');
|
||||
|
||||
const steerReplyRequestId = String(options.crossConversation?.replyRequestId || '').trim();
|
||||
if (steerReplyRequestId) registerCrossConversationReplyRequest(entry, steerReplyRequestId);
|
||||
const abandonSteeredCrossConversationReply = () => {
|
||||
if (!steerReplyRequestId) return;
|
||||
unregisterCrossConversationReplyRequest(entry, steerReplyRequestId);
|
||||
deletePendingCrossConversationReply(steerReplyRequestId);
|
||||
};
|
||||
|
||||
let persistedUserMessage = null;
|
||||
|
||||
if (ws) {
|
||||
@@ -13135,6 +13353,7 @@ function handleCodexAppSteerMessage(ws, msg, options = {}) {
|
||||
|
||||
waitForSteerReady().then((ready) => {
|
||||
if (!ready) {
|
||||
abandonSteeredCrossConversationReply();
|
||||
sendSteerStatus('failed', '插入失败');
|
||||
wsSend(entry.ws || ws, {
|
||||
type: 'error',
|
||||
@@ -13151,6 +13370,7 @@ function handleCodexAppSteerMessage(ws, msg, options = {}) {
|
||||
}
|
||||
const currentSession = loadSession(sessionId);
|
||||
if (!currentSession || !isCodexAppSession(currentSession)) {
|
||||
abandonSteeredCrossConversationReply();
|
||||
sendSteerStatus('failed', '插入失败');
|
||||
wsSend(entry.ws || ws, {
|
||||
type: 'error',
|
||||
@@ -13176,6 +13396,9 @@ function handleCodexAppSteerMessage(ws, msg, options = {}) {
|
||||
if (decoratorResolution.mentions.length > 0) {
|
||||
persistedUserMessage.composerMentions = decoratorResolution.mentions;
|
||||
}
|
||||
if (options.crossConversation) {
|
||||
persistedUserMessage.crossConversation = options.crossConversation;
|
||||
}
|
||||
currentSession.messages.push(persistedUserMessage);
|
||||
currentSession.updated = new Date().toISOString();
|
||||
saveSession(currentSession);
|
||||
@@ -13213,6 +13436,7 @@ function handleCodexAppSteerMessage(ws, msg, options = {}) {
|
||||
});
|
||||
}).catch((err) => {
|
||||
if (isCodexAppNoActiveTurnError(err) && activeCodexAppTurns.get(sessionId) === entry) {
|
||||
unregisterCrossConversationReplyRequest(entry, steerReplyRequestId);
|
||||
// app-server 已确认旧 turn 不存在:先收敛旧输出,再复用已持久化的消息直接启动新 turn。
|
||||
handleCodexAppTurnComplete(sessionId, {
|
||||
beforeUserMessage: {
|
||||
@@ -13227,6 +13451,7 @@ function handleCodexAppSteerMessage(ws, msg, options = {}) {
|
||||
&& !activeCodexAppTurns.has(sessionId)
|
||||
? handleCodexAppMessage(ws, refreshedSession, runtimeTextValue, resolvedAttachments, {
|
||||
mcpContext: entry.mcpContext || options.mcpContext || {},
|
||||
crossConversation: options.crossConversation || null,
|
||||
})
|
||||
: null;
|
||||
if (restarted?.ok) {
|
||||
@@ -13253,6 +13478,7 @@ function handleCodexAppSteerMessage(ws, msg, options = {}) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
abandonSteeredCrossConversationReply();
|
||||
rollbackPersistedCodexAppSteerMessage(sessionId, userMessageId);
|
||||
sendSteerStatus('failed', '插入失败');
|
||||
wsSend(entry.ws || ws, {
|
||||
|
||||
Reference in New Issue
Block a user