feat: add conversation search and usage dashboard
This commit is contained in:
188
server.js
188
server.js
@@ -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', {
|
||||
|
||||
Reference in New Issue
Block a user