feat: 优化任务状态与图片 MCP 提示
This commit is contained in:
229
server.js
229
server.js
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user