mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-08 13:38:24 +00:00
263 lines
7.9 KiB
TypeScript
263 lines
7.9 KiB
TypeScript
import fs from 'node:fs/promises';
|
|
|
|
import { sessionsDb } from '@/modules/database/index.js';
|
|
import type { IProviderSessions } from '@/shared/interfaces.js';
|
|
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
|
import { createNormalizedMessage, generateMessageId, readObjectRecord } from '@/shared/utils.js';
|
|
|
|
const PROVIDER = 'gemini';
|
|
|
|
async function getGeminiCliSessionMessages(sessionId: string): Promise<AnyRecord[]> {
|
|
const sessionFilePath = sessionsDb.getSessionById(sessionId)?.jsonl_path;
|
|
if (!sessionFilePath) {
|
|
return [];
|
|
}
|
|
|
|
try {
|
|
const data = await fs.readFile(sessionFilePath, 'utf8');
|
|
const session = JSON.parse(data) as AnyRecord;
|
|
const sourceMessages = Array.isArray(session.messages) ? session.messages : [];
|
|
|
|
return sourceMessages.map((msg: AnyRecord) => {
|
|
const role = msg.type === 'user'
|
|
? 'user'
|
|
: (msg.type === 'gemini' || msg.type === 'assistant')
|
|
? 'assistant'
|
|
: msg.type;
|
|
|
|
let content = '';
|
|
if (typeof msg.content === 'string') {
|
|
content = msg.content;
|
|
} else if (Array.isArray(msg.content)) {
|
|
content = msg.content
|
|
.filter((part: AnyRecord) => part?.text)
|
|
.map((part: AnyRecord) => part.text)
|
|
.join('\n');
|
|
}
|
|
|
|
return {
|
|
type: 'message',
|
|
message: { role, content },
|
|
timestamp: msg.timestamp || null,
|
|
};
|
|
});
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export class GeminiSessionsProvider implements IProviderSessions {
|
|
/**
|
|
* Normalizes live Gemini stream-json events into the shared message shape.
|
|
*
|
|
* Gemini history uses a different session file shape, so fetchHistory handles
|
|
* that separately after loading raw persisted messages.
|
|
*/
|
|
normalizeMessage(rawMessage: unknown, sessionId: string | null): NormalizedMessage[] {
|
|
const raw = readObjectRecord(rawMessage);
|
|
if (!raw) {
|
|
return [];
|
|
}
|
|
|
|
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 messages: NormalizedMessage[] = [];
|
|
if (content) {
|
|
messages.push(createNormalizedMessage({
|
|
id: baseId,
|
|
sessionId,
|
|
timestamp: ts,
|
|
provider: PROVIDER,
|
|
kind: 'stream_delta',
|
|
content,
|
|
}));
|
|
}
|
|
if (raw.delta !== true) {
|
|
messages.push(createNormalizedMessage({
|
|
sessionId,
|
|
timestamp: ts,
|
|
provider: PROVIDER,
|
|
kind: 'stream_end',
|
|
}));
|
|
}
|
|
return messages;
|
|
}
|
|
|
|
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 messages = [createNormalizedMessage({
|
|
sessionId,
|
|
timestamp: ts,
|
|
provider: PROVIDER,
|
|
kind: 'stream_end',
|
|
})];
|
|
if (raw.stats?.total_tokens) {
|
|
messages.push(createNormalizedMessage({
|
|
sessionId,
|
|
timestamp: ts,
|
|
provider: PROVIDER,
|
|
kind: 'status',
|
|
text: 'Complete',
|
|
tokens: raw.stats.total_tokens,
|
|
canInterrupt: false,
|
|
}));
|
|
}
|
|
return messages;
|
|
}
|
|
|
|
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 [];
|
|
}
|
|
|
|
/**
|
|
* Loads Gemini history from Gemini CLI session files on disk.
|
|
*/
|
|
async fetchHistory(
|
|
sessionId: string,
|
|
options: FetchHistoryOptions = {},
|
|
): Promise<FetchHistoryResult> {
|
|
const { limit = null, offset = 0 } = options;
|
|
|
|
let rawMessages: AnyRecord[];
|
|
try {
|
|
rawMessages = await getGeminiCliSessionMessages(sessionId);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
console.warn(`[GeminiProvider] Failed to load session ${sessionId}:`, message);
|
|
return { messages: [], total: 0, hasMore: false, offset: 0, limit: null };
|
|
}
|
|
|
|
const normalized: NormalizedMessage[] = [];
|
|
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');
|
|
|
|
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,
|
|
}));
|
|
}
|
|
}
|
|
|
|
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 };
|
|
}
|
|
}
|
|
}
|
|
|
|
const start = Math.max(0, offset);
|
|
const pageLimit = limit === null ? null : Math.max(0, limit);
|
|
const messages = pageLimit === null
|
|
? normalized.slice(start)
|
|
: normalized.slice(start, start + pageLimit);
|
|
|
|
return {
|
|
messages,
|
|
total: normalized.length,
|
|
hasMore: pageLimit === null ? false : start + pageLimit < normalized.length,
|
|
offset: start,
|
|
limit: pageLimit,
|
|
};
|
|
}
|
|
}
|