mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-22 06:17:24 +00:00
- Add provider adapter layer (server/providers/) with registry pattern
- Claude, Cursor, Codex, Gemini adapters normalize native formats to NormalizedMessage
- Shared types.js defines ProviderAdapter interface and message kinds
- Registry enables polymorphic provider lookup
- Add unified REST endpoint: GET /api/sessions/:id/messages?provider=...
- Replaces four provider-specific message endpoints with one
- Delegates to provider adapters via registry
- Add frontend session-keyed store (useSessionStore)
- Per-session Map with serverMessages/realtimeMessages/merged
- Dedup by ID, stale threshold for re-fetch, background session accumulation
- No localStorage for messages — backend JSONL is source of truth
- Add normalizedToChatMessages converter (useChatMessages)
- Converts NormalizedMessage[] to existing ChatMessage[] UI format
- Wire unified store into ChatInterface, useChatSessionState, useChatRealtimeHandlers
- Session switch uses store cache for instant render
- Background WebSocket messages routed to correct session slot
187 lines
6.1 KiB
JavaScript
187 lines
6.1 KiB
JavaScript
/**
|
|
* Gemini provider adapter.
|
|
*
|
|
* Normalizes Gemini CLI session history into NormalizedMessage format.
|
|
* @module adapters/gemini
|
|
*/
|
|
|
|
import sessionManager from '../../sessionManager.js';
|
|
import { getGeminiCliSessionMessages } from '../../projects.js';
|
|
import { createNormalizedMessage, generateMessageId } from '../types.js';
|
|
|
|
const PROVIDER = 'gemini';
|
|
|
|
/**
|
|
* Normalize a realtime NDJSON event from Gemini CLI into NormalizedMessage(s).
|
|
* Handles: message (delta/final), tool_use, tool_result, result, error.
|
|
* @param {object} raw - A parsed NDJSON event
|
|
* @param {string} sessionId
|
|
* @returns {import('../types.js').NormalizedMessage[]}
|
|
*/
|
|
export function normalizeMessage(raw, sessionId) {
|
|
const ts = raw.timestamp || new Date().toISOString();
|
|
const baseId = raw.uuid || generateMessageId('gemini');
|
|
|
|
if (raw.type === 'message' && raw.role === 'assistant') {
|
|
const content = raw.content || '';
|
|
const msgs = [];
|
|
if (content) {
|
|
msgs.push(createNormalizedMessage({ id: baseId, sessionId, timestamp: ts, provider: PROVIDER, kind: 'stream_delta', content }));
|
|
}
|
|
// If not a delta, also send stream_end
|
|
if (raw.delta !== true) {
|
|
msgs.push(createNormalizedMessage({ sessionId, timestamp: ts, provider: PROVIDER, kind: 'stream_end' }));
|
|
}
|
|
return msgs;
|
|
}
|
|
|
|
if (raw.type === 'tool_use') {
|
|
return [createNormalizedMessage({
|
|
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
|
|
kind: 'tool_use', toolName: raw.tool_name, toolInput: raw.parameters || {},
|
|
toolId: raw.tool_id || baseId,
|
|
})];
|
|
}
|
|
|
|
if (raw.type === 'tool_result') {
|
|
return [createNormalizedMessage({
|
|
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
|
|
kind: 'tool_result', toolId: raw.tool_id || '',
|
|
content: raw.output === undefined ? '' : String(raw.output),
|
|
isError: raw.status === 'error',
|
|
})];
|
|
}
|
|
|
|
if (raw.type === 'result') {
|
|
const msgs = [createNormalizedMessage({ sessionId, timestamp: ts, provider: PROVIDER, kind: 'stream_end' })];
|
|
if (raw.stats?.total_tokens) {
|
|
msgs.push(createNormalizedMessage({
|
|
sessionId, timestamp: ts, provider: PROVIDER,
|
|
kind: 'status', text: 'Complete', tokens: raw.stats.total_tokens, canInterrupt: false,
|
|
}));
|
|
}
|
|
return msgs;
|
|
}
|
|
|
|
if (raw.type === 'error') {
|
|
return [createNormalizedMessage({
|
|
id: baseId, sessionId, timestamp: ts, provider: PROVIDER,
|
|
kind: 'error', content: raw.error || raw.message || 'Unknown Gemini streaming error',
|
|
})];
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* @type {import('../types.js').ProviderAdapter}
|
|
*/
|
|
export const geminiAdapter = {
|
|
normalizeMessage,
|
|
/**
|
|
* Fetch session history for Gemini.
|
|
* First tries in-memory session manager, then falls back to CLI sessions on disk.
|
|
*/
|
|
async fetchHistory(sessionId, opts = {}) {
|
|
let rawMessages;
|
|
try {
|
|
rawMessages = sessionManager.getSessionMessages(sessionId);
|
|
|
|
// Fallback to Gemini CLI sessions on disk
|
|
if (rawMessages.length === 0) {
|
|
rawMessages = await getGeminiCliSessionMessages(sessionId);
|
|
}
|
|
} catch (error) {
|
|
console.warn(`[GeminiAdapter] Failed to load session ${sessionId}:`, error.message);
|
|
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
|
|
}
|
|
|
|
const normalized = [];
|
|
for (let i = 0; i < rawMessages.length; i++) {
|
|
const raw = rawMessages[i];
|
|
const ts = raw.timestamp || new Date().toISOString();
|
|
const baseId = raw.uuid || generateMessageId('gemini');
|
|
|
|
// sessionManager format: { type: 'message', message: { role, content }, timestamp }
|
|
// CLI format: { role: 'user'|'gemini'|'assistant', content: string|array }
|
|
const role = raw.message?.role || raw.role;
|
|
const content = raw.message?.content || raw.content;
|
|
|
|
if (!role || !content) continue;
|
|
|
|
const normalizedRole = (role === 'user') ? 'user' : 'assistant';
|
|
|
|
if (Array.isArray(content)) {
|
|
for (let partIdx = 0; partIdx < content.length; partIdx++) {
|
|
const part = content[partIdx];
|
|
if (part.type === 'text' && part.text) {
|
|
normalized.push(createNormalizedMessage({
|
|
id: `${baseId}_${partIdx}`,
|
|
sessionId,
|
|
timestamp: ts,
|
|
provider: PROVIDER,
|
|
kind: 'text',
|
|
role: normalizedRole,
|
|
content: part.text,
|
|
}));
|
|
} else if (part.type === 'tool_use') {
|
|
normalized.push(createNormalizedMessage({
|
|
id: `${baseId}_${partIdx}`,
|
|
sessionId,
|
|
timestamp: ts,
|
|
provider: PROVIDER,
|
|
kind: 'tool_use',
|
|
toolName: part.name,
|
|
toolInput: part.input,
|
|
toolId: part.id || generateMessageId('gemini_tool'),
|
|
}));
|
|
} else if (part.type === 'tool_result') {
|
|
normalized.push(createNormalizedMessage({
|
|
id: `${baseId}_${partIdx}`,
|
|
sessionId,
|
|
timestamp: ts,
|
|
provider: PROVIDER,
|
|
kind: 'tool_result',
|
|
toolId: part.tool_use_id || '',
|
|
content: part.content === undefined ? '' : String(part.content),
|
|
isError: Boolean(part.is_error),
|
|
}));
|
|
}
|
|
}
|
|
} else if (typeof content === 'string' && content.trim()) {
|
|
normalized.push(createNormalizedMessage({
|
|
id: baseId,
|
|
sessionId,
|
|
timestamp: ts,
|
|
provider: PROVIDER,
|
|
kind: 'text',
|
|
role: normalizedRole,
|
|
content,
|
|
}));
|
|
}
|
|
}
|
|
|
|
// Attach tool results to tool_use messages
|
|
const toolResultMap = new Map();
|
|
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 tr = toolResultMap.get(msg.toolId);
|
|
msg.toolResult = { content: tr.content, isError: tr.isError };
|
|
}
|
|
}
|
|
|
|
return {
|
|
messages: normalized,
|
|
total: normalized.length,
|
|
hasMore: false,
|
|
offset: 0,
|
|
limit: null,
|
|
};
|
|
},
|
|
};
|