feat: add conversation search and usage dashboard

This commit is contained in:
shiyue
2026-08-03 18:20:33 +08:00
parent cc600bdf31
commit 58d5f816c2
30 changed files with 6316 additions and 22 deletions

188
server.js
View File

@@ -10,6 +10,8 @@ const { createCodexAppServerClient } = require('./lib/codex-app-server-client');
const { createCodexAppWorkerClient } = require('./lib/codex-app-worker-client');
const { createCodexAppRuntime } = require('./lib/codex-app-runtime');
const { createCodexRolloutStore } = require('./lib/codex-rollouts');
const { createSessionSearchIndex } = require('./lib/session-search-index');
const { createUsageStatisticsIndex, UsageStatisticsError } = require('./lib/usage-statistics');
const { TOOLS: CCWEB_MCP_TOOLS } = require('./lib/ccweb-mcp-server');
const CCWEB_MCP_SERVER_INFO = { name: 'ccweb', version: '1.0.0' };
@@ -126,6 +128,7 @@ const CODEX_APP_CCWEB_MCP_BEARER_TOKEN_ENV = 'CC_WEB_CODEX_APP_MCP_TOKEN';
const CODEX_APP_CCWEB_MCP_TRANSPORT = normalizeCodexAppCcwebMcpTransport(process.env.CC_WEB_CODEX_APP_CCWEB_MCP_TRANSPORT);
const CODEX_APP_WORKER_DISABLED = /^(0|false|no|off)$/i.test(String(process.env.CC_WEB_CODEX_APP_WORKER || ''));
const CODEX_APP_WORKER_ENABLED = !CODEX_APP_WORKER_DISABLED;
const USAGE_STATISTICS_ENABLED = !/^(0|false|no|off)$/i.test(String(process.env.CC_WEB_USAGE_STATISTICS || ''));
const CODEX_APP_PROCESS_ENV_STRIP_KEYS = [
'CC_WEB_MCP_URL',
'CC_WEB_MCP_TOKEN',
@@ -219,6 +222,56 @@ function plog(level, event, data = {}) {
} catch {}
}
const sessionSearchIndex = createSessionSearchIndex({
sessionsDir: SESSIONS_DIR,
maxFileBytes: SESSION_LOAD_MAX_BYTES,
maxQueryChars: 200,
maxResults: 50,
snippetChars: 220,
logger: {
warn: (event, data) => plog('WARN', event, data),
error: (event, data) => plog('ERROR', event, data),
log: (event, data) => plog('INFO', event, data),
},
});
const sessionSearchIndexReady = sessionSearchIndex.initialize();
const usageStatisticsIndex = USAGE_STATISTICS_ENABLED ? createUsageStatisticsIndex({
sessionsDir: SESSIONS_DIR,
maxFileBytes: SESSION_LOAD_MAX_BYTES,
maxRangeDays: 370,
logger: {
warn: (event, data) => plog('WARN', event, data),
error: (event, data) => plog('ERROR', event, data),
log: (event, data) => plog('INFO', event, data),
},
}) : null;
function scheduleUsageStatisticsUpsert(sessionId) {
if (!usageStatisticsIndex) return false;
try {
return usageStatisticsIndex.scheduleUpsert(sessionId);
} catch (err) {
plog('WARN', 'usage_statistics_schedule_ignored', {
sessionId: String(sessionId || '').slice(0, 8),
error: err?.message || String(err || ''),
});
return false;
}
}
function removeUsageStatisticsSession(sessionId) {
if (!usageStatisticsIndex) return false;
try {
return usageStatisticsIndex.remove(sessionId);
} catch (err) {
plog('WARN', 'usage_statistics_remove_ignored', {
sessionId: String(sessionId || '').slice(0, 8),
error: err?.message || String(err || ''),
});
return false;
}
}
// === Notification System ===
const DEFAULT_SUMMARY_CONFIG = {
enabled: false,
@@ -3896,6 +3949,8 @@ function saveSession(session) {
});
}
updateSessionRuntimeThreadIndex(session);
sessionSearchIndex.scheduleUpsert(session.id);
scheduleUsageStatisticsUpsert(session.id);
return true;
} catch (err) {
plog('ERROR', 'session_save_failed', {
@@ -4517,6 +4572,108 @@ function sendSessionList(ws) {
}
}
async function handleSearchSessions(ws, msg = {}) {
const requestId = String(msg.requestId || '').trim().slice(0, 160);
const query = String(msg.query || '').trim().slice(0, 200);
const sendSearchError = (code, message) => wsSend(ws, {
type: 'session_search_error',
requestId,
code,
message,
});
if (!requestId) return sendSearchError('invalid_request_id', '检索请求缺少 requestId');
if (query.length < 2) return sendSearchError('invalid_query', '请输入至少 2 个字符');
const now = Date.now();
const previousSearchAt = Number(ws._ccWebSessionSearchAt || 0);
if (previousSearchAt && now - previousSearchAt < 120) {
return sendSearchError('rate_limited', '检索过于频繁,请稍后重试');
}
ws._ccWebSessionSearchAt = now;
try {
const status = await sessionSearchIndexReady;
if (!status?.ready) return sendSearchError('index_unavailable', '会话索引暂不可用,请稍后重试');
await sessionSearchIndex.flush();
const result = sessionSearchIndex.search({
query,
agent: msg.agent,
sort: msg.sort === 'newest' ? 'newest' : 'relevance',
matchMode: msg.matchMode === 'word' ? 'word' : 'contains',
limit: Math.max(1, Math.min(50, Number.parseInt(String(msg.limit || '50'), 10) || 50)),
});
const totalMatches = result.results.reduce((sum, item) => {
return sum + Math.max(1, Number(item.matchedMessageCount || item.matches?.length || 0));
}, 0);
wsSend(ws, {
type: 'session_search_results',
requestId,
total: result.total,
totalMatches,
tookMs: result.tookMs,
indexState: result.indexState,
results: result.results,
});
} catch (err) {
plog('WARN', 'session_search_request_failed', {
requestId: requestId.slice(0, 32),
error: err?.message || String(err || ''),
});
sendSearchError('search_failed', '检索失败,请稍后重试');
}
}
async function handleUsageStatisticsQuery(ws, msg = {}) {
const requestId = String(msg.requestId || '').trim().slice(0, 160);
const sendUsageError = (code, message) => wsSend(ws, {
type: 'usage_stats_error',
requestId,
code,
message,
});
if (!requestId) return sendUsageError('invalid_request_id', '统计请求缺少 requestId');
if (!USAGE_STATISTICS_ENABLED || !usageStatisticsIndex) {
return sendUsageError('disabled', '使用统计功能当前未启用');
}
const now = Date.now();
const previousQueryAt = Number(ws._ccWebUsageStatisticsAt || 0);
if (previousQueryAt && now - previousQueryAt < 250) {
return sendUsageError('rate_limited', '统计刷新过于频繁,请稍后重试');
}
ws._ccWebUsageStatisticsAt = now;
try {
const status = await usageStatisticsIndex.initialize();
if (!status?.ready) return sendUsageError('index_unavailable', '统计索引暂不可用,请稍后重试');
await usageStatisticsIndex.flush();
const result = usageStatisticsIndex.query({
from: String(msg.from || '').slice(0, 80),
to: String(msg.to || '').slice(0, 80),
timeZone: String(msg.timeZone || 'UTC').slice(0, 80),
recentLimit: 50,
mcpToolLimit: 200,
skillLimit: 100,
mcpDetailLimit: 250,
});
wsSend(ws, {
type: 'usage_stats_result',
requestId,
...result,
});
} catch (err) {
const known = err instanceof UsageStatisticsError;
plog(known ? 'WARN' : 'ERROR', 'usage_statistics_query_failed', {
requestId: requestId.slice(0, 32),
code: known ? err.code : 'query_failed',
error: err?.message || String(err || ''),
});
sendUsageError(known ? err.code : 'query_failed', known ? err.message : '统计查询失败,请稍后重试');
}
}
function broadcastSessionList() {
if (!wss) return;
for (const client of wss.clients) {
@@ -6392,7 +6549,13 @@ wss.on('connection', (ws, req) => {
authToken = msg.token && activeTokens.has(msg.token) ? msg.token : crypto.randomBytes(32).toString('hex');
activeTokens.add(authToken);
authenticated = true;
wsSend(ws, { type: 'auth_result', success: true, token: authToken, mustChangePassword: !!authConfig.mustChange });
wsSend(ws, {
type: 'auth_result',
success: true,
token: authToken,
mustChangePassword: !!authConfig.mustChange,
features: { usageStatistics: USAGE_STATISTICS_ENABLED },
});
sendSessionList(ws);
} else {
const justBanned = recordAuthFailure(clientIP);
@@ -6433,6 +6596,12 @@ wss.on('connection', (ws, req) => {
case 'load_history_page':
handleLoadHistoryPage(ws, msg);
break;
case 'search_sessions':
handleSearchSessions(ws, msg);
break;
case 'usage_stats_query':
handleUsageStatisticsQuery(ws, msg);
break;
case 'delete_session':
handleDeleteSession(ws, msg.sessionId);
break;
@@ -7714,7 +7883,20 @@ function handleLoadSession(ws, msg) {
saveSession(refreshedSession);
}
}
const { recentMessages, olderChunks, historyRemaining, historyBuffered } = splitHistoryMessages(refreshedSession.messages);
const requestedTargetMessageIndex = Number.parseInt(String(
typeof msg === 'object' ? msg?.targetMessageIndex ?? '' : '',
), 10);
const targetMessageIndex = Number.isFinite(requestedTargetMessageIndex)
? Math.max(0, Math.min(refreshedSession.messages.length - 1, requestedTargetMessageIndex))
: null;
const recentHistoryBaseIndex = Math.max(0, refreshedSession.messages.length - INITIAL_HISTORY_COUNT);
const targetPrefetchChunks = targetMessageIndex !== null && targetMessageIndex < recentHistoryBaseIndex
? Math.ceil((recentHistoryBaseIndex - targetMessageIndex) / HISTORY_CHUNK_SIZE)
: 0;
const { recentMessages, olderChunks, historyRemaining, historyBuffered } = splitHistoryMessages(
refreshedSession.messages,
{ prefetchChunks: Math.max(HISTORY_PREFETCH_CHUNKS, targetPrefetchChunks) },
);
const effectiveCwd = refreshedSession.cwd || activeProcesses.get(sessionId)?.cwd || activeCodexAppTurns.get(sessionId)?.cwd || null;
const waitState = crossConversationWaitState(sessionId);
@@ -7884,6 +8066,8 @@ function handleDeleteSession(ws, sessionId) {
removeAttachmentById(attachmentId);
}
if (fs.existsSync(p)) fs.unlinkSync(p);
sessionSearchIndex.remove(sessionId);
removeUsageStatisticsSession(sessionId);
if (sessionAgent === 'codex') {
const result = deleteCodexLocalSession(session);
plog('INFO', 'codex_local_session_deleted', {