mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-04-18 11:31:30 +00:00
Move provider-specific normalizeMessage and fetchHistory logic out of the legacy server/providers adapters and into the refactored provider classes so callers can depend on the main provider contract instead of parallel adapter plumbing. Add a providers service to resolve concrete providers through the registry and delegate message normalization/history loading from realtime handlers and the unified messages route. Add shared TypeScript message/history types and normalized message helpers so provider implementations and callers use the same contract. Remove the old adapter registry/files now that Claude, Codex, Cursor, and Gemini implement the required behavior directly.
333 lines
9.6 KiB
TypeScript
333 lines
9.6 KiB
TypeScript
import { getCodexSessionMessages } from '@/projects.js';
|
|
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
|
|
import { CodexMcpProvider } from '@/modules/providers/list/codex/codex-mcp.provider.js';
|
|
import type { FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
|
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
|
|
|
|
const PROVIDER = 'codex';
|
|
|
|
type RawProviderMessage = Record<string, any>;
|
|
|
|
type CodexHistoryResult =
|
|
| RawProviderMessage[]
|
|
| {
|
|
messages?: RawProviderMessage[];
|
|
total?: number;
|
|
hasMore?: boolean;
|
|
tokenUsage?: unknown;
|
|
};
|
|
|
|
const loadCodexSessionMessages = getCodexSessionMessages as unknown as (
|
|
sessionId: string,
|
|
limit: number | null,
|
|
offset: number,
|
|
) => Promise<CodexHistoryResult>;
|
|
|
|
function readRawProviderMessage(raw: unknown): RawProviderMessage | null {
|
|
return readObjectRecord(raw) as RawProviderMessage | null;
|
|
}
|
|
|
|
export class CodexProvider extends AbstractProvider {
|
|
readonly mcp = new CodexMcpProvider();
|
|
|
|
constructor() {
|
|
super('codex');
|
|
}
|
|
|
|
/**
|
|
* Normalizes a persisted Codex JSONL entry.
|
|
*
|
|
* Live Codex SDK events are transformed before they reach normalizeMessage(),
|
|
* while history entries already use a compact message/tool shape from projects.js.
|
|
*/
|
|
private normalizeHistoryEntry(raw: RawProviderMessage, sessionId: string | null): NormalizedMessage[] {
|
|
const ts = raw.timestamp || new Date().toISOString();
|
|
const baseId = raw.uuid || generateMessageId('codex');
|
|
|
|
if (raw.message?.role === 'user') {
|
|
const content = typeof raw.message.content === 'string'
|
|
? raw.message.content
|
|
: Array.isArray(raw.message.content)
|
|
? raw.message.content
|
|
.map((part: string | RawProviderMessage) => typeof part === 'string' ? part : part?.text || '')
|
|
.filter(Boolean)
|
|
.join('\n')
|
|
: String(raw.message.content || '');
|
|
if (!content.trim()) {
|
|
return [];
|
|
}
|
|
return [createNormalizedMessage({
|
|
id: baseId,
|
|
sessionId,
|
|
timestamp: ts,
|
|
provider: PROVIDER,
|
|
kind: 'text',
|
|
role: 'user',
|
|
content,
|
|
})];
|
|
}
|
|
|
|
if (raw.message?.role === 'assistant') {
|
|
const content = typeof raw.message.content === 'string'
|
|
? raw.message.content
|
|
: Array.isArray(raw.message.content)
|
|
? raw.message.content
|
|
.map((part: string | RawProviderMessage) => typeof part === 'string' ? part : part?.text || '')
|
|
.filter(Boolean)
|
|
.join('\n')
|
|
: '';
|
|
if (!content.trim()) {
|
|
return [];
|
|
}
|
|
return [createNormalizedMessage({
|
|
id: baseId,
|
|
sessionId,
|
|
timestamp: ts,
|
|
provider: PROVIDER,
|
|
kind: 'text',
|
|
role: 'assistant',
|
|
content,
|
|
})];
|
|
}
|
|
|
|
if (raw.type === 'thinking' || raw.isReasoning) {
|
|
return [createNormalizedMessage({
|
|
id: baseId,
|
|
sessionId,
|
|
timestamp: ts,
|
|
provider: PROVIDER,
|
|
kind: 'thinking',
|
|
content: raw.message?.content || '',
|
|
})];
|
|
}
|
|
|
|
if (raw.type === 'tool_use' || raw.toolName) {
|
|
return [createNormalizedMessage({
|
|
id: baseId,
|
|
sessionId,
|
|
timestamp: ts,
|
|
provider: PROVIDER,
|
|
kind: 'tool_use',
|
|
toolName: raw.toolName || 'Unknown',
|
|
toolInput: raw.toolInput,
|
|
toolId: raw.toolCallId || baseId,
|
|
})];
|
|
}
|
|
|
|
if (raw.type === 'tool_result') {
|
|
return [createNormalizedMessage({
|
|
id: baseId,
|
|
sessionId,
|
|
timestamp: ts,
|
|
provider: PROVIDER,
|
|
kind: 'tool_result',
|
|
toolId: raw.toolCallId || '',
|
|
content: raw.output || '',
|
|
isError: Boolean(raw.isError),
|
|
})];
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Normalizes either a Codex history entry or a transformed live SDK event.
|
|
*/
|
|
normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] {
|
|
const raw = readRawProviderMessage(rawMessage);
|
|
if (!raw) {
|
|
return [];
|
|
}
|
|
|
|
if (raw.message?.role) {
|
|
return this.normalizeHistoryEntry(raw, sessionId);
|
|
}
|
|
|
|
const ts = raw.timestamp || new Date().toISOString();
|
|
const baseId = raw.uuid || generateMessageId('codex');
|
|
|
|
if (raw.type === 'item') {
|
|
switch (raw.itemType) {
|
|
case 'agent_message':
|
|
return [createNormalizedMessage({
|
|
id: baseId,
|
|
sessionId,
|
|
timestamp: ts,
|
|
provider: PROVIDER,
|
|
kind: 'text',
|
|
role: 'assistant',
|
|
content: raw.message?.content || '',
|
|
})];
|
|
case 'reasoning':
|
|
return [createNormalizedMessage({
|
|
id: baseId,
|
|
sessionId,
|
|
timestamp: ts,
|
|
provider: PROVIDER,
|
|
kind: 'thinking',
|
|
content: raw.message?.content || '',
|
|
})];
|
|
case 'command_execution':
|
|
return [createNormalizedMessage({
|
|
id: baseId,
|
|
sessionId,
|
|
timestamp: ts,
|
|
provider: PROVIDER,
|
|
kind: 'tool_use',
|
|
toolName: 'Bash',
|
|
toolInput: { command: raw.command },
|
|
toolId: baseId,
|
|
output: raw.output,
|
|
exitCode: raw.exitCode,
|
|
status: raw.status,
|
|
})];
|
|
case 'file_change':
|
|
return [createNormalizedMessage({
|
|
id: baseId,
|
|
sessionId,
|
|
timestamp: ts,
|
|
provider: PROVIDER,
|
|
kind: 'tool_use',
|
|
toolName: 'FileChanges',
|
|
toolInput: raw.changes,
|
|
toolId: baseId,
|
|
status: raw.status,
|
|
})];
|
|
case 'mcp_tool_call':
|
|
return [createNormalizedMessage({
|
|
id: baseId,
|
|
sessionId,
|
|
timestamp: ts,
|
|
provider: PROVIDER,
|
|
kind: 'tool_use',
|
|
toolName: raw.tool || 'MCP',
|
|
toolInput: raw.arguments,
|
|
toolId: baseId,
|
|
server: raw.server,
|
|
result: raw.result,
|
|
error: raw.error,
|
|
status: raw.status,
|
|
})];
|
|
case 'web_search':
|
|
return [createNormalizedMessage({
|
|
id: baseId,
|
|
sessionId,
|
|
timestamp: ts,
|
|
provider: PROVIDER,
|
|
kind: 'tool_use',
|
|
toolName: 'WebSearch',
|
|
toolInput: { query: raw.query },
|
|
toolId: baseId,
|
|
})];
|
|
case 'todo_list':
|
|
return [createNormalizedMessage({
|
|
id: baseId,
|
|
sessionId,
|
|
timestamp: ts,
|
|
provider: PROVIDER,
|
|
kind: 'tool_use',
|
|
toolName: 'TodoList',
|
|
toolInput: { items: raw.items },
|
|
toolId: baseId,
|
|
})];
|
|
case 'error':
|
|
return [createNormalizedMessage({
|
|
id: baseId,
|
|
sessionId,
|
|
timestamp: ts,
|
|
provider: PROVIDER,
|
|
kind: 'error',
|
|
content: raw.message?.content || 'Unknown error',
|
|
})];
|
|
default:
|
|
return [createNormalizedMessage({
|
|
id: baseId,
|
|
sessionId,
|
|
timestamp: ts,
|
|
provider: PROVIDER,
|
|
kind: 'tool_use',
|
|
toolName: raw.itemType || 'Unknown',
|
|
toolInput: raw.item || raw,
|
|
toolId: baseId,
|
|
})];
|
|
}
|
|
}
|
|
|
|
if (raw.type === 'turn_complete') {
|
|
return [createNormalizedMessage({
|
|
id: baseId,
|
|
sessionId,
|
|
timestamp: ts,
|
|
provider: PROVIDER,
|
|
kind: 'complete',
|
|
})];
|
|
}
|
|
if (raw.type === 'turn_failed') {
|
|
return [createNormalizedMessage({
|
|
id: baseId,
|
|
sessionId,
|
|
timestamp: ts,
|
|
provider: PROVIDER,
|
|
kind: 'error',
|
|
content: raw.error?.message || 'Turn failed',
|
|
})];
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Loads Codex JSONL history and keeps token usage metadata when projects.js
|
|
* provides it.
|
|
*/
|
|
async fetchHistory(
|
|
sessionId: string,
|
|
options: FetchHistoryOptions = {},
|
|
): Promise<FetchHistoryResult> {
|
|
const { limit = null, offset = 0 } = options;
|
|
|
|
let result: CodexHistoryResult;
|
|
try {
|
|
result = await loadCodexSessionMessages(sessionId, limit, offset);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
console.warn(`[CodexProvider] Failed to load session ${sessionId}:`, message);
|
|
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
|
|
}
|
|
|
|
const rawMessages = Array.isArray(result) ? result : (result.messages || []);
|
|
const total = Array.isArray(result) ? rawMessages.length : (result.total || 0);
|
|
const hasMore = Array.isArray(result) ? false : Boolean(result.hasMore);
|
|
const tokenUsage = Array.isArray(result) ? undefined : result.tokenUsage;
|
|
|
|
const normalized: NormalizedMessage[] = [];
|
|
for (const raw of rawMessages) {
|
|
normalized.push(...this.normalizeHistoryEntry(raw, sessionId));
|
|
}
|
|
|
|
const toolResultMap = new Map<string, NormalizedMessage>();
|
|
for (const msg of normalized) {
|
|
if (msg.kind === 'tool_result' && msg.toolId) {
|
|
toolResultMap.set(msg.toolId, msg);
|
|
}
|
|
}
|
|
for (const msg of normalized) {
|
|
if (msg.kind === 'tool_use' && msg.toolId && toolResultMap.has(msg.toolId)) {
|
|
const toolResult = toolResultMap.get(msg.toolId);
|
|
if (toolResult) {
|
|
msg.toolResult = { content: toolResult.content, isError: toolResult.isError };
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
messages: normalized,
|
|
total,
|
|
hasMore,
|
|
offset,
|
|
limit,
|
|
tokenUsage,
|
|
};
|
|
}
|
|
}
|