feat: 优化任务状态与图片 MCP 提示

This commit is contained in:
shiyue
2026-08-13 00:34:37 +08:00
parent 86231f0d97
commit 214ff4ec88
13 changed files with 1353 additions and 49 deletions

229
server.js
View File

@@ -31,6 +31,10 @@ const {
LIFECYCLE_EVENT_TYPES: TASK_BOARD_LIFECYCLE_EVENTS,
createTaskBoardLifecycle,
} = require('./lib/task-board-lifecycle');
const {
CLASSIFICATION_EVENT_TYPES: TASK_STATUS_CLASSIFICATION_EVENTS,
createTaskStatusClassifier,
} = require('./lib/task-board-classifier');
const CCWEB_MCP_SERVER_INFO = { name: 'ccweb', version: '1.0.0' };
if (process.argv.includes('--ccweb-mcp-server')) {
@@ -790,7 +794,7 @@ const pendingCodexAppApprovals = new Map();
let taskBoardService = null;
let taskBoardMcpHandlers = null;
let taskBoardLifecycle = null;
const taskBoardMcpFingerprintBySession = new Map();
let taskStatusClassifier = null;
let codexAppClient = null;
let codexAppClientSignature = '';
const CODEX_APP_STATE_FILE = 'codexapp-state.json';
@@ -1152,6 +1156,107 @@ function loadLocalCodexTomlConfig() {
}
}
function loadLocalCodexProviderConfig() {
try {
const configPath = getLocalCodexConfigTomlPath();
if (!configPath || !fs.existsSync(configPath)) return null;
const text = fs.readFileSync(configPath, 'utf8');
const root = {};
const providers = new Map();
let section = [];
for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const sectionMatch = trimmed.match(/^\[([^\]]+)\]$/);
if (sectionMatch) {
section = parseTomlBareKeyPath(sectionMatch[1]);
continue;
}
const eqIndex = trimmed.indexOf('=');
if (eqIndex <= 0) continue;
const key = String(trimmed.slice(0, eqIndex)).trim();
if (!key) continue;
const value = parseTomlValue(trimmed.slice(eqIndex + 1));
if (section.length === 0) {
root[key] = value;
} else if (section[0] === 'model_providers' && section[1]) {
const provider = providers.get(section[1]) || {};
provider[key] = value;
providers.set(section[1], provider);
}
}
const providerId = String(root.model_provider || 'openai').trim();
const provider = providers.get(providerId) || {};
const apiBase = String(provider.base_url || process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1').trim();
const wireApi = String(provider.wire_api || 'responses').trim().toLowerCase();
const configuredEnvKey = String(provider.env_key || '').trim();
const envKey = configuredEnvKey || (providerId === 'openai' ? 'OPENAI_API_KEY' : `${providerId}_OPENAI_API_KEY`);
return {
providerId,
providerName: String(provider.name || providerId).trim() || providerId,
apiBase,
wireApi,
envKey,
model: String(root.model || '').trim(),
reasoningEffort: String(root.model_reasoning_effort || '').trim().toLowerCase(),
};
} catch {
return null;
}
}
function loadLocalCodexApiKey(providerConfig) {
if (!providerConfig) return '';
const envKey = String(providerConfig.envKey || '').trim();
if (envKey && typeof process.env[envKey] === 'string' && process.env[envKey].trim()) {
return process.env[envKey].trim();
}
try {
const codexHome = String(process.env.CODEX_HOME || '').trim()
|| path.join(process.env.HOME || process.env.USERPROFILE || '', '.codex');
const authPath = path.join(codexHome, 'auth.json');
if (!fs.existsSync(authPath)) return '';
const auth = JSON.parse(fs.readFileSync(authPath, 'utf8'));
if (!auth || typeof auth !== 'object' || Array.isArray(auth)) return '';
const candidates = [envKey, 'OPENAI_API_KEY'].filter(Boolean);
for (const key of candidates) {
if (typeof auth[key] === 'string' && auth[key].trim()) return auth[key].trim();
}
} catch {}
return '';
}
function resolveTaskStatusClassifierRuntime(session) {
if (!session || !isCodexAppSession(session)) return null;
const modelSettings = codexAppModelSettings(session);
const codexConfig = loadCodexConfig();
if (codexConfig.mode === 'custom') {
const profile = (codexConfig.profiles || [])
.find((item) => item.name === codexConfig.activeProfile) || null;
if (!profile?.apiKey || !profile?.apiBase || !modelSettings.model) return null;
return {
apiBase: profile.apiBase,
apiKey: profile.apiKey,
model: modelSettings.model,
effort: modelSettings.effort,
providerName: profile.name || 'custom',
wireApi: 'responses',
};
}
const provider = loadLocalCodexProviderConfig();
if (!provider || provider.wireApi !== 'responses') return null;
const apiKey = loadLocalCodexApiKey(provider);
if (!apiKey) return null;
return {
apiBase: provider.apiBase,
apiKey,
model: modelSettings.model || provider.model,
effort: modelSettings.effort || (CODEX_REASONING_LEVELS.has(provider.reasoningEffort) ? provider.reasoningEffort : null),
providerName: provider.providerName,
wireApi: provider.wireApi,
};
}
function getDefaultCodexModel() {
const localConfig = loadLocalCodexTomlConfig();
const model = String(localConfig.model || '').trim() || FALLBACK_CODEX_MODEL;
@@ -4195,11 +4300,36 @@ function loadSession(id) {
}
}
function preserveNewerTaskTrackingForSessionSave(session, targetPath) {
if (!fs.existsSync(targetPath)) return;
let persisted;
try {
persisted = safeReadSessionJson(targetPath, SESSION_LOAD_MAX_BYTES, { sessionId: session.id });
} catch {
return;
}
if (!persisted?.taskTracking || typeof persisted.taskTracking !== 'object') return;
const persistedVersion = Number.isSafeInteger(persisted.taskTracking.version)
? persisted.taskTracking.version
: 0;
const incomingVersion = Number.isSafeInteger(session.taskTracking?.version)
? session.taskTracking.version
: -1;
if (incomingVersion >= persistedVersion) return;
session.taskTracking = persisted.taskTracking;
plog('INFO', 'task_tracking_preserved_on_session_save', {
sessionId: String(session.id || '').slice(0, 8),
incomingVersion,
persistedVersion,
});
}
function saveSession(session) {
if (!session?.id) return false;
normalizeSession(session);
const targetPath = sessionPath(session.id);
try {
preserveNewerTaskTrackingForSessionSave(session, targetPath);
const result = buildSessionJsonForPersist(session);
writeFileAtomicSync(targetPath, result.json);
if (result.guarded) {
@@ -4288,6 +4418,33 @@ taskBoardLifecycle = createTaskBoardLifecycle(taskBoardService, {
plog(String(level || '').toUpperCase() === 'ERROR' ? 'ERROR' : 'WARN', event, data);
},
});
taskStatusClassifier = createTaskStatusClassifier({
taskBoardService,
loadSession,
resolveRuntime: resolveTaskStatusClassifierRuntime,
logger(level, event, data) {
plog(level, event, data);
},
onTaskChanged(event) {
if (!event?.result) return;
broadcastTaskBoardEvent(`classifier:${event.eventType || 'updated'}`, event.result, {
source: 'classifier',
});
broadcastSessionList();
},
});
function enqueueTaskStatusClassification(sessionId, event = {}) {
if (!taskStatusClassifier || !sessionId) return Promise.resolve(null);
return taskStatusClassifier.enqueue(sessionId, event).catch((error) => {
plog('WARN', 'task_status_classification_enqueue_failed', {
sessionId: String(sessionId).slice(0, 8),
eventType: event?.eventType || '',
error: error?.message || String(error || ''),
});
return null;
});
}
function taskBoardMcpToolDefinitionsForSession(sessionId) {
if (!taskBoardService || !sessionId) return [];
@@ -4311,39 +4468,6 @@ function taskBoardMcpSchemaFingerprint(sessionId) {
.slice(0, 24);
}
async function ensureTaskBoardMcpToolsFresh(client, session, existingThreadId) {
const sessionId = sanitizeId(session?.id || '');
if (!sessionId) return;
const fingerprint = taskBoardMcpSchemaFingerprint(sessionId);
const previous = taskBoardMcpFingerprintBySession.get(sessionId);
if (!existingThreadId) {
taskBoardMcpFingerprintBySession.set(sessionId, fingerprint);
return;
}
if (previous === fingerprint) return;
try {
if (typeof client.reloadMcpServers === 'function') await client.reloadMcpServers();
else await client.request('config/mcpServer/reload', {}, 30000);
plog('INFO', 'task_board_mcp_tools_reloaded', {
sessionId: sessionId.slice(0, 8),
previous: previous || null,
fingerprint,
});
} catch (error) {
const unsupported = error?.code === -32601
|| /not found|unknown|unsupported|method/i.test(String(error?.message || ''));
if (!unsupported) throw error;
// thread/resume 会携带带 fingerprint 的新 MCP URL/env使旧连接键失效。
plog('WARN', 'task_board_mcp_reload_unsupported_using_config_fingerprint', {
sessionId: sessionId.slice(0, 8),
fingerprint,
error: error?.message || String(error || ''),
});
}
taskBoardMcpFingerprintBySession.set(sessionId, fingerprint);
}
function taskTrackingSnapshotForSession(sessionOrId) {
const sessionId = typeof sessionOrId === 'string' ? sessionOrId : sessionOrId?.id;
if (!sessionId || !taskBoardService) return null;
@@ -9198,12 +9322,20 @@ function handleMessage(ws, msg, options = {}) {
const currentSessionId = session.id;
if (!hideInHistory) {
const eventId = msg.clientMessageId || persistedUserMessage?.timestamp || null;
dispatchTaskBoardLifecycle(currentSessionId, {
type: TASK_BOARD_LIFECYCLE_EVENTS.USER_MESSAGE_RECEIVED,
eventId: msg.clientMessageId || persistedUserMessage?.timestamp || null,
eventId,
occurredAt: persistedUserMessage?.timestamp || session.updated,
trackingEnabled: session.taskTracking?.enabled === true,
});
if (isCodexAppSession(session)) {
void enqueueTaskStatusClassification(currentSessionId, {
eventType: TASK_STATUS_CLASSIFICATION_EVENTS.USER_MESSAGE_RECEIVED,
eventId,
userMessage: textValue,
});
}
}
if (ws) {
@@ -11115,7 +11247,6 @@ async function startCodexAppTurn(sessionId, input) {
await client.start();
const currentThreadId = getRuntimeSessionId(session);
await ensureTaskBoardMcpToolsFresh(client, session, currentThreadId);
const expectedThreadId = entry.expectedThreadId
|| entry.codexRetry?.expectedThreadId
|| entry.retryRequest?.expectedThreadId
@@ -11242,6 +11373,22 @@ function handleCodexAppTurnComplete(sessionId, options = {}) {
outcome: completionError ? 'failed' : 'completed',
trackingEnabled: entry.taskTrackingEnabled === true,
});
if (session && !completionError && !options.interrupted && !entry.userAborted) {
const toolEvidence = assistantToolCalls.length > 0
? truncateTextValue(JSON.stringify(assistantToolCalls.map((toolCall) => ({
name: toolCall?.name || '',
kind: toolCall?.kind || '',
done: toolCall?.done !== false,
result: toolCall?.result ?? null,
}))), 5000)
: '';
void enqueueTaskStatusClassification(sessionId, {
eventType: TASK_STATUS_CLASSIFICATION_EVENTS.TURN_COMPLETED,
eventId: entry.turnId || turnKey,
assistantResult: assistantContent,
toolEvidence,
});
}
if (entry.crossConversationReplyRequestId) {
completeCrossConversationReply(entry.crossConversationReplyRequestId, entry, session);
}
@@ -11467,6 +11614,11 @@ function handleCodexAppSteerMessage(ws, msg, options = {}) {
input,
clientUserMessageId: userMessageId,
}, 60000).then(() => {
void enqueueTaskStatusClassification(sessionId, {
eventType: TASK_STATUS_CLASSIFICATION_EVENTS.USER_MESSAGE_RECEIVED,
eventId: userMessageId,
userMessage: textValue,
});
sendSteerStatus('inserted', '已插入');
wsSend(entry.ws || ws, {
type: 'system_message',
@@ -11495,6 +11647,11 @@ function handleCodexAppSteerMessage(ws, msg, options = {}) {
})
: null;
if (restarted?.ok) {
void enqueueTaskStatusClassification(sessionId, {
eventType: TASK_STATUS_CLASSIFICATION_EVENTS.USER_MESSAGE_RECEIVED,
eventId: userMessageId,
userMessage: textValue,
});
wsSend(entry.ws || ws, {
type: 'resume_generating',
sessionId,