feat: support custom instance icons and refresh release

This commit is contained in:
shiyue
2026-08-25 22:11:07 +08:00
parent bd20a79d4b
commit 05480e511d
20 changed files with 1104 additions and 23 deletions

240
server.js
View File

@@ -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 工具可能来自多个 server20 条会在空查询时静默截断大部分工具。
@@ -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, {