mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-07 06:02:43 +08:00
fix: remove gemini support since google discontinued it
This commit is contained in:
@@ -445,11 +445,6 @@ export const CURSOR_FALLBACK_MODELS: ProviderModelsDefinition = {
|
||||
label: "gpt-5.2-xhigh-fast",
|
||||
description: "GPT-5.2 Extra High Fast",
|
||||
},
|
||||
{
|
||||
value: "gemini-3.1-pro",
|
||||
label: "gemini-3.1-pro",
|
||||
description: "Gemini 3.1 Pro",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.4-mini-none",
|
||||
label: "gpt-5.4-mini-none",
|
||||
@@ -530,16 +525,6 @@ export const CURSOR_FALLBACK_MODELS: ProviderModelsDefinition = {
|
||||
label: "gpt-5.1-high",
|
||||
description: "GPT-5.1 High",
|
||||
},
|
||||
{
|
||||
value: "gemini-3-flash",
|
||||
label: "gemini-3-flash",
|
||||
description: "Gemini 3 Flash",
|
||||
},
|
||||
{
|
||||
value: "gemini-3.5-flash",
|
||||
label: "gemini-3.5-flash",
|
||||
description: "Gemini 3.5 Flash",
|
||||
},
|
||||
{
|
||||
value: "gpt-5.1-codex-mini-low",
|
||||
label: "gpt-5.1-codex-mini-low",
|
||||
|
||||
@@ -1,307 +0,0 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import spawn from 'cross-spawn';
|
||||
|
||||
import type { IProviderAuth } from '@/shared/interfaces.js';
|
||||
import type { ProviderAuthStatus } from '@/shared/types.js';
|
||||
import { readObjectRecord, readOptionalString } from '@/shared/utils.js';
|
||||
|
||||
type GeminiCredentialsStatus = {
|
||||
authenticated: boolean;
|
||||
email: string | null;
|
||||
method: string | null;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type GeminiAuthType =
|
||||
| 'oauth-personal'
|
||||
| 'gemini-api-key'
|
||||
| 'vertex-ai'
|
||||
| 'compute-default-credentials'
|
||||
| 'gateway'
|
||||
| 'cloud-shell'
|
||||
| null;
|
||||
|
||||
export class GeminiProviderAuth implements IProviderAuth {
|
||||
/**
|
||||
* Gemini CLI can override its home root via GEMINI_CLI_HOME.
|
||||
* Use the same resolution so status checks match runtime behavior.
|
||||
*/
|
||||
private getGeminiCliHome(): string {
|
||||
return process.env.GEMINI_CLI_HOME?.trim() || os.homedir();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the Gemini CLI is available on this host.
|
||||
*/
|
||||
private checkInstalled(): boolean {
|
||||
const cliPath = process.env.GEMINI_PATH || 'gemini';
|
||||
try {
|
||||
spawn.sync(cliPath, ['--version'], { stdio: 'ignore', timeout: 5000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns Gemini CLI installation and credential status.
|
||||
*/
|
||||
async getStatus(): Promise<ProviderAuthStatus> {
|
||||
const installed = this.checkInstalled();
|
||||
|
||||
if (!installed) {
|
||||
return {
|
||||
installed,
|
||||
provider: 'gemini',
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: 'Gemini CLI is not installed',
|
||||
};
|
||||
}
|
||||
|
||||
const credentials = await this.checkCredentials();
|
||||
|
||||
return {
|
||||
installed,
|
||||
provider: 'gemini',
|
||||
authenticated: credentials.authenticated,
|
||||
email: credentials.email,
|
||||
method: credentials.method,
|
||||
error: credentials.authenticated ? undefined : credentials.error || 'Not authenticated',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses dotenv-style key/value pairs.
|
||||
*/
|
||||
private parseEnvFile(content: string): Record<string, string> {
|
||||
const parsed: Record<string, string> = {};
|
||||
|
||||
for (const rawLine of content.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedLine = line.startsWith('export ')
|
||||
? line.slice('export '.length).trim()
|
||||
: line;
|
||||
const separatorIndex = normalizedLine.indexOf('=');
|
||||
if (separatorIndex <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = normalizedLine.slice(0, separatorIndex).trim();
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let value = normalizedLine.slice(separatorIndex + 1).trim();
|
||||
const quoted = (value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\''));
|
||||
if (quoted) {
|
||||
value = value.slice(1, -1);
|
||||
} else {
|
||||
value = value.replace(/\s+#.*$/, '').trim();
|
||||
}
|
||||
|
||||
parsed[key] = value;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads user-level auth env in Gemini's "first file found" order.
|
||||
*/
|
||||
private async loadUserLevelAuthEnv(): Promise<Record<string, string>> {
|
||||
const geminiCliHome = this.getGeminiCliHome();
|
||||
const envCandidates = [
|
||||
path.join(geminiCliHome, '.gemini', '.env'),
|
||||
path.join(geminiCliHome, '.env'),
|
||||
];
|
||||
|
||||
for (const envPath of envCandidates) {
|
||||
try {
|
||||
const content = await readFile(envPath, 'utf8');
|
||||
return this.parseEnvFile(content);
|
||||
} catch {
|
||||
// Continue to the next fallback.
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads Gemini's selected auth type from settings.json when available.
|
||||
*/
|
||||
private async readSelectedAuthType(): Promise<GeminiAuthType> {
|
||||
try {
|
||||
const settingsPath = path.join(this.getGeminiCliHome(), '.gemini', 'settings.json');
|
||||
const content = await readFile(settingsPath, 'utf8');
|
||||
const settings = readObjectRecord(JSON.parse(content));
|
||||
const security = readObjectRecord(settings?.security);
|
||||
const auth = readObjectRecord(security?.auth);
|
||||
const selectedType = readOptionalString(auth?.selectedType);
|
||||
if (!selectedType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return selectedType as GeminiAuthType;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks Gemini credentials from API key env vars or local OAuth credential files.
|
||||
*/
|
||||
private async checkCredentials(): Promise<GeminiCredentialsStatus> {
|
||||
if (process.env.GEMINI_API_KEY?.trim()) {
|
||||
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
|
||||
}
|
||||
|
||||
const userEnv = await this.loadUserLevelAuthEnv();
|
||||
if (readOptionalString(userEnv.GEMINI_API_KEY)) {
|
||||
return { authenticated: true, email: 'API Key Auth', method: 'api_key' };
|
||||
}
|
||||
|
||||
const selectedType = await this.readSelectedAuthType();
|
||||
if (selectedType === 'vertex-ai') {
|
||||
const hasGoogleApiKey = Boolean(
|
||||
process.env.GOOGLE_API_KEY?.trim()
|
||||
|| readOptionalString(userEnv.GOOGLE_API_KEY)
|
||||
);
|
||||
const hasProject = Boolean(
|
||||
process.env.GOOGLE_CLOUD_PROJECT?.trim()
|
||||
|| process.env.GOOGLE_CLOUD_PROJECT_ID?.trim()
|
||||
|| readOptionalString(userEnv.GOOGLE_CLOUD_PROJECT)
|
||||
|| readOptionalString(userEnv.GOOGLE_CLOUD_PROJECT_ID)
|
||||
);
|
||||
const hasLocation = Boolean(
|
||||
process.env.GOOGLE_CLOUD_LOCATION?.trim()
|
||||
|| readOptionalString(userEnv.GOOGLE_CLOUD_LOCATION)
|
||||
);
|
||||
const hasServiceAccount = Boolean(
|
||||
process.env.GOOGLE_APPLICATION_CREDENTIALS?.trim()
|
||||
|| readOptionalString(userEnv.GOOGLE_APPLICATION_CREDENTIALS)
|
||||
);
|
||||
|
||||
if (hasGoogleApiKey || hasServiceAccount || (hasProject && hasLocation)) {
|
||||
return { authenticated: true, email: 'Vertex AI Auth', method: 'vertex_ai' };
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: 'vertex_ai',
|
||||
error: 'Gemini is set to Vertex AI, but required env vars are missing',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const credsPath = path.join(this.getGeminiCliHome(), '.gemini', 'oauth_creds.json');
|
||||
const content = await readFile(credsPath, 'utf8');
|
||||
const creds = readObjectRecord(JSON.parse(content)) ?? {};
|
||||
const accessToken = readOptionalString(creds.access_token);
|
||||
|
||||
if (!accessToken) {
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: 'No valid tokens found in oauth_creds',
|
||||
};
|
||||
}
|
||||
|
||||
const refreshToken = readOptionalString(creds.refresh_token);
|
||||
const tokenInfo = await this.getTokenInfoEmail(accessToken);
|
||||
if (tokenInfo.valid) {
|
||||
return {
|
||||
authenticated: true,
|
||||
email: tokenInfo.email || 'OAuth Session',
|
||||
method: 'credentials_file',
|
||||
};
|
||||
}
|
||||
|
||||
if (!refreshToken) {
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: 'credentials_file',
|
||||
error: 'Access token invalid and no refresh token found',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: true,
|
||||
email: await this.getActiveAccountEmail() || 'OAuth Session',
|
||||
method: 'credentials_file',
|
||||
};
|
||||
} catch {
|
||||
if (selectedType === 'gemini-api-key') {
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: 'api_key',
|
||||
error: 'Gemini is set to "Use Gemini API key", but GEMINI_API_KEY is unavailable',
|
||||
};
|
||||
}
|
||||
|
||||
if (selectedType === 'oauth-personal') {
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: 'credentials_file',
|
||||
error: 'Gemini is set to Google sign-in, but no cached OAuth credentials were found',
|
||||
};
|
||||
}
|
||||
|
||||
// If no explicit auth type was selected, surface the generic "not configured" error.
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
method: null,
|
||||
error: 'Gemini CLI not configured',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a Gemini OAuth access token and returns an email when Google reports one.
|
||||
*/
|
||||
private async getTokenInfoEmail(accessToken: string): Promise<{ valid: boolean; email: string | null }> {
|
||||
try {
|
||||
const tokenRes = await fetch(`https://oauth2.googleapis.com/tokeninfo?access_token=${accessToken}`);
|
||||
if (!tokenRes.ok) {
|
||||
return { valid: false, email: null };
|
||||
}
|
||||
|
||||
const tokenInfo = readObjectRecord(await tokenRes.json());
|
||||
return {
|
||||
valid: true,
|
||||
email: readOptionalString(tokenInfo?.email) ?? null,
|
||||
};
|
||||
} catch {
|
||||
return { valid: false, email: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads Gemini's active local Google account as an offline fallback for display.
|
||||
*/
|
||||
private async getActiveAccountEmail(): Promise<string | null> {
|
||||
try {
|
||||
const accPath = path.join(this.getGeminiCliHome(), '.gemini', 'google_accounts.json');
|
||||
const accContent = await readFile(accPath, 'utf8');
|
||||
const accounts = readObjectRecord(JSON.parse(accContent));
|
||||
return readOptionalString(accounts?.active) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { McpProvider } from '@/modules/providers/shared/mcp/mcp.provider.js';
|
||||
import type { McpScope, ProviderMcpServer, UpsertProviderMcpServerInput } from '@/shared/types.js';
|
||||
import {
|
||||
AppError,
|
||||
readJsonConfig,
|
||||
readObjectRecord,
|
||||
readOptionalString,
|
||||
readStringArray,
|
||||
readStringRecord,
|
||||
writeJsonConfig,
|
||||
} from '@/shared/utils.js';
|
||||
|
||||
export class GeminiMcpProvider extends McpProvider {
|
||||
constructor() {
|
||||
super('gemini', ['user', 'project'], ['stdio', 'http', 'sse']);
|
||||
}
|
||||
|
||||
protected async readScopedServers(scope: McpScope, workspacePath: string): Promise<Record<string, unknown>> {
|
||||
const filePath = scope === 'user'
|
||||
? path.join(os.homedir(), '.gemini', 'settings.json')
|
||||
: path.join(workspacePath, '.gemini', 'settings.json');
|
||||
const config = await readJsonConfig(filePath);
|
||||
return readObjectRecord(config.mcpServers) ?? {};
|
||||
}
|
||||
|
||||
protected async writeScopedServers(
|
||||
scope: McpScope,
|
||||
workspacePath: string,
|
||||
servers: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const filePath = scope === 'user'
|
||||
? path.join(os.homedir(), '.gemini', 'settings.json')
|
||||
: path.join(workspacePath, '.gemini', 'settings.json');
|
||||
const config = await readJsonConfig(filePath);
|
||||
config.mcpServers = servers;
|
||||
await writeJsonConfig(filePath, config);
|
||||
}
|
||||
|
||||
protected buildServerConfig(input: UpsertProviderMcpServerInput): Record<string, unknown> {
|
||||
if (input.transport === 'stdio') {
|
||||
if (!input.command?.trim()) {
|
||||
throw new AppError('command is required for stdio MCP servers.', {
|
||||
code: 'MCP_COMMAND_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
command: input.command,
|
||||
args: input.args ?? [],
|
||||
env: input.env ?? {},
|
||||
cwd: input.cwd,
|
||||
};
|
||||
}
|
||||
|
||||
if (!input.url?.trim()) {
|
||||
throw new AppError('url is required for http/sse MCP servers.', {
|
||||
code: 'MCP_URL_REQUIRED',
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
type: input.transport,
|
||||
url: input.url,
|
||||
headers: input.headers ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
protected normalizeServerConfig(
|
||||
scope: McpScope,
|
||||
name: string,
|
||||
rawConfig: unknown,
|
||||
): ProviderMcpServer | null {
|
||||
if (!rawConfig || typeof rawConfig !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const config = rawConfig as Record<string, unknown>;
|
||||
if (typeof config.command === 'string') {
|
||||
return {
|
||||
provider: 'gemini',
|
||||
name,
|
||||
scope,
|
||||
transport: 'stdio',
|
||||
command: config.command,
|
||||
args: readStringArray(config.args),
|
||||
env: readStringRecord(config.env),
|
||||
cwd: readOptionalString(config.cwd),
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof config.url === 'string') {
|
||||
const transport = readOptionalString(config.type) === 'sse' ? 'sse' : 'http';
|
||||
return {
|
||||
provider: 'gemini',
|
||||
name,
|
||||
scope,
|
||||
transport,
|
||||
url: config.url,
|
||||
headers: readStringRecord(config.headers),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import type { IProviderModels } from '@/shared/interfaces.js';
|
||||
import type {
|
||||
ProviderChangeActiveModelInput,
|
||||
ProviderCurrentActiveModel,
|
||||
ProviderModelsDefinition,
|
||||
ProviderSessionActiveModelChange,
|
||||
} from '@/shared/types.js';
|
||||
import {
|
||||
buildDefaultProviderCurrentActiveModel,
|
||||
writeProviderSessionActiveModelChange,
|
||||
} from '@/shared/utils.js';
|
||||
|
||||
export const GEMINI_FALLBACK_MODELS: ProviderModelsDefinition = {
|
||||
OPTIONS: [
|
||||
{ value: 'gemini-3-flash-preview', label: 'Gemini 3 Flash Preview' },
|
||||
{ value: 'gemini-3.1-flash-lite-preview', label: 'Gemini 3.1 Flash Lite Preview' },
|
||||
{ value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
|
||||
{ value: 'gemini-2.5-flash-lite', label: 'Gemini 2.5 Flash Lite' },
|
||||
{ value: 'gemma-4-31b-it', label: 'Gemma 4 31B IT' },
|
||||
{ value: 'gemma-4-26b-a4b-it', label: 'Gemma 4 26B A4B IT' },
|
||||
],
|
||||
DEFAULT: 'gemini-3-flash-preview',
|
||||
};
|
||||
|
||||
export class GeminiProviderModels implements IProviderModels {
|
||||
async getSupportedModels(): Promise<ProviderModelsDefinition> {
|
||||
return GEMINI_FALLBACK_MODELS;
|
||||
}
|
||||
|
||||
async getCurrentActiveModel(): Promise<ProviderCurrentActiveModel> {
|
||||
return buildDefaultProviderCurrentActiveModel(GEMINI_FALLBACK_MODELS);
|
||||
}
|
||||
|
||||
async changeActiveModel(
|
||||
input: ProviderChangeActiveModelInput,
|
||||
): Promise<ProviderSessionActiveModelChange> {
|
||||
return writeProviderSessionActiveModelChange('gemini', input);
|
||||
}
|
||||
}
|
||||
@@ -1,405 +0,0 @@
|
||||
import crypto from 'node:crypto';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
|
||||
import { projectsDb, sessionsDb } from '@/modules/database/index.js';
|
||||
import {
|
||||
findFilesRecursivelyCreatedAfter,
|
||||
normalizeProjectPath,
|
||||
normalizeSessionName,
|
||||
readFileTimestamps,
|
||||
} from '@/shared/utils.js';
|
||||
import type { IProviderSessionSynchronizer } from '@/shared/interfaces.js';
|
||||
import type { AnyRecord } from '@/shared/types.js';
|
||||
|
||||
type ParsedSession = {
|
||||
sessionId: string;
|
||||
projectPath: string;
|
||||
sessionName?: string;
|
||||
};
|
||||
|
||||
type GeminiJsonlMetadata = {
|
||||
sessionId: string;
|
||||
projectPath?: string;
|
||||
projectHash?: string;
|
||||
firstUserMessage?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Session indexer for Gemini transcript artifacts.
|
||||
*/
|
||||
export class GeminiSessionSynchronizer implements IProviderSessionSynchronizer {
|
||||
private readonly provider = 'gemini' as const;
|
||||
private readonly geminiHome = path.join(os.homedir(), '.gemini');
|
||||
|
||||
/**
|
||||
* Scans Gemini legacy JSON and new JSONL artifacts and upserts sessions into DB.
|
||||
*/
|
||||
async synchronize(since?: Date): Promise<number> {
|
||||
const projectHashLookup = this.buildProjectHashLookup();
|
||||
|
||||
// const legacySessionFiles = await findFilesRecursivelyCreatedAfter(
|
||||
// path.join(this.geminiHome, 'sessions'),
|
||||
// '.json',
|
||||
// since ?? null
|
||||
// );
|
||||
// Gemini creates overlapping artifacts across `sessions/` and `tmp/`.
|
||||
// We currently index only `tmp/*/chats/*.jsonl` because those files are the
|
||||
// live transcript source and avoid duplicate session rows from mirrored files.
|
||||
// const legacyTempFiles = await findFilesRecursivelyCreatedAfter(
|
||||
// path.join(this.geminiHome, 'tmp'),
|
||||
// '.json',
|
||||
// since ?? null
|
||||
// );
|
||||
// const jsonlSessionFiles = await findFilesRecursivelyCreatedAfter(
|
||||
// path.join(this.geminiHome, 'sessions'),
|
||||
// '.jsonl',
|
||||
// since ?? null
|
||||
// );
|
||||
const jsonlTempFiles = await findFilesRecursivelyCreatedAfter(
|
||||
path.join(this.geminiHome, 'tmp'),
|
||||
'.jsonl',
|
||||
since ?? null
|
||||
);
|
||||
|
||||
// Current strategy: index only temp chat JSONL artifacts.
|
||||
const files = [
|
||||
// ...legacySessionFiles,
|
||||
// Intentionally disabled to avoid duplicate indexing from mirrored
|
||||
// `sessions/*.json` and `sessions/*.jsonl` artifacts.
|
||||
// ...legacyTempFiles,
|
||||
// ...jsonlSessionFiles,
|
||||
...jsonlTempFiles,
|
||||
];
|
||||
|
||||
let processed = 0;
|
||||
for (const filePath of files) {
|
||||
if (this.shouldSkipTempArtifact(filePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsed = filePath.endsWith('.jsonl')
|
||||
? await this.processJsonlSessionFile(filePath, projectHashLookup)
|
||||
: await this.processLegacySessionFile(filePath);
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const timestamps = await readFileTimestamps(filePath);
|
||||
sessionsDb.createSession(
|
||||
parsed.sessionId,
|
||||
this.provider,
|
||||
parsed.projectPath,
|
||||
parsed.sessionName,
|
||||
timestamps.createdAt,
|
||||
timestamps.updatedAt,
|
||||
filePath
|
||||
);
|
||||
processed += 1;
|
||||
}
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses and upserts one Gemini legacy JSON or JSONL artifact.
|
||||
*/
|
||||
async synchronizeFile(filePath: string): Promise<string | null> {
|
||||
if (!filePath.endsWith('.json') && !filePath.endsWith('.jsonl')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.shouldSkipTempArtifact(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = filePath.endsWith('.jsonl')
|
||||
? await this.processJsonlSessionFile(filePath, this.buildProjectHashLookup())
|
||||
: await this.processLegacySessionFile(filePath);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timestamps = await readFileTimestamps(filePath);
|
||||
return sessionsDb.createSession(
|
||||
parsed.sessionId,
|
||||
this.provider,
|
||||
parsed.projectPath,
|
||||
parsed.sessionName,
|
||||
timestamps.createdAt,
|
||||
timestamps.updatedAt,
|
||||
filePath
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts session metadata from one Gemini legacy JSON artifact.
|
||||
*/
|
||||
private async processLegacySessionFile(filePath: string): Promise<ParsedSession | null> {
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
const data = JSON.parse(content) as AnyRecord;
|
||||
|
||||
const sessionId =
|
||||
typeof data.sessionId === 'string'
|
||||
? data.sessionId
|
||||
: typeof data.id === 'string'
|
||||
? data.id
|
||||
: undefined;
|
||||
if (!sessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const workspaceProjectPath = await this.resolveProjectPathFromChatWorkspace(filePath);
|
||||
const projectPath = typeof data.projectPath === 'string' && data.projectPath.trim().length > 0
|
||||
? data.projectPath
|
||||
: workspaceProjectPath;
|
||||
if (!projectPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const messages = Array.isArray(data.messages) ? data.messages : [];
|
||||
const firstMessage = messages[0] as AnyRecord | undefined;
|
||||
let rawName: string | undefined;
|
||||
|
||||
if (Array.isArray(firstMessage?.content) && typeof firstMessage.content[0]?.text === 'string') {
|
||||
rawName = firstMessage.content[0].text;
|
||||
} else if (typeof firstMessage?.content === 'string') {
|
||||
rawName = firstMessage.content;
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
projectPath,
|
||||
sessionName: normalizeSessionName(rawName, 'New Gemini Chat'),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts session metadata from one Gemini JSONL artifact.
|
||||
*/
|
||||
private async processJsonlSessionFile(
|
||||
filePath: string,
|
||||
projectHashLookup: Map<string, string>
|
||||
): Promise<ParsedSession | null> {
|
||||
const metadata = await this.extractJsonlMetadata(filePath);
|
||||
if (!metadata) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let projectPath = typeof metadata.projectPath === 'string' ? metadata.projectPath.trim() : '';
|
||||
if (!projectPath) {
|
||||
const workspaceProjectPath = await this.resolveProjectPathFromChatWorkspace(filePath);
|
||||
if (workspaceProjectPath) {
|
||||
projectPath = workspaceProjectPath;
|
||||
}
|
||||
}
|
||||
if (!projectPath && typeof metadata.projectHash === 'string') {
|
||||
projectPath = projectHashLookup.get(metadata.projectHash.trim().toLowerCase()) ?? '';
|
||||
}
|
||||
if (!projectPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Once we resolve a project hash/path pair, keep it in-memory for this sync run.
|
||||
if (typeof metadata.projectHash === 'string' && metadata.projectHash.trim()) {
|
||||
projectHashLookup.set(metadata.projectHash.trim().toLowerCase(), projectPath);
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId: metadata.sessionId,
|
||||
projectPath,
|
||||
sessionName: normalizeSessionName(metadata.firstUserMessage, 'New Gemini Chat'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads first useful metadata from Gemini JSONL files.
|
||||
*/
|
||||
private async extractJsonlMetadata(filePath: string): Promise<GeminiJsonlMetadata | null> {
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
let sessionId: string | undefined;
|
||||
let projectPath: string | undefined;
|
||||
let projectHash: string | undefined;
|
||||
let firstUserMessage: string | undefined;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let parsed: AnyRecord;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed) as AnyRecord;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!sessionId && typeof parsed.sessionId === 'string') {
|
||||
sessionId = parsed.sessionId;
|
||||
}
|
||||
if (!projectPath && typeof parsed.projectPath === 'string') {
|
||||
projectPath = parsed.projectPath;
|
||||
}
|
||||
if (!projectHash && typeof parsed.projectHash === 'string') {
|
||||
projectHash = parsed.projectHash;
|
||||
}
|
||||
|
||||
if (!firstUserMessage && parsed.type === 'user') {
|
||||
firstUserMessage = this.extractGeminiTextContent(parsed.content);
|
||||
}
|
||||
|
||||
if (sessionId && (projectPath || projectHash) && firstUserMessage) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
projectPath,
|
||||
projectHash,
|
||||
firstUserMessage,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to resolve project root from Gemini tmp chat workspaces.
|
||||
*/
|
||||
private async resolveProjectPathFromChatWorkspace(filePath: string): Promise<string> {
|
||||
if (!filePath.includes(`${path.sep}chats${path.sep}`)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const chatsDir = path.dirname(filePath);
|
||||
const workspaceDir = path.dirname(chatsDir);
|
||||
const projectRootPath = path.join(workspaceDir, '.project_root');
|
||||
|
||||
try {
|
||||
const rootContent = await readFile(projectRootPath, 'utf8');
|
||||
return rootContent.trim();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a hash->path lookup for Gemini JSONL metadata that stores projectHash.
|
||||
*/
|
||||
private buildProjectHashLookup(): Map<string, string> {
|
||||
const lookup = new Map<string, string>();
|
||||
const knownPaths = new Set<string>();
|
||||
|
||||
for (const project of projectsDb.getProjectPaths()) {
|
||||
if (typeof project.project_path === 'string' && project.project_path.trim()) {
|
||||
knownPaths.add(project.project_path.trim());
|
||||
}
|
||||
}
|
||||
|
||||
for (const session of sessionsDb.getAllSessions()) {
|
||||
if (session.provider === this.provider && typeof session.project_path === 'string' && session.project_path.trim()) {
|
||||
knownPaths.add(session.project_path.trim());
|
||||
}
|
||||
}
|
||||
|
||||
for (const knownPath of knownPaths) {
|
||||
this.addProjectHashCandidates(lookup, knownPath);
|
||||
}
|
||||
|
||||
return lookup;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds likely Gemini hash variants for one project path.
|
||||
*/
|
||||
private addProjectHashCandidates(lookup: Map<string, string>, projectPath: string): void {
|
||||
const trimmed = projectPath.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = normalizeProjectPath(trimmed);
|
||||
const resolved = path.resolve(trimmed);
|
||||
const resolvedNormalized = normalizeProjectPath(resolved);
|
||||
|
||||
const candidates = new Set<string>([
|
||||
trimmed,
|
||||
normalized,
|
||||
resolved,
|
||||
resolvedNormalized,
|
||||
]);
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
for (const candidate of [...candidates]) {
|
||||
candidates.add(candidate.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hash = this.sha256(candidate);
|
||||
if (!lookup.has(hash)) {
|
||||
lookup.set(hash, trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns first user text from Gemini content payload shapes.
|
||||
*/
|
||||
private extractGeminiTextContent(content: unknown): string | undefined {
|
||||
if (typeof content === 'string' && content.trim().length > 0) {
|
||||
return content;
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const part of content) {
|
||||
if (typeof part === 'string' && part.trim().length > 0) {
|
||||
return part;
|
||||
}
|
||||
|
||||
if (part && typeof part === 'object' && typeof (part as AnyRecord).text === 'string') {
|
||||
const text = (part as AnyRecord).text;
|
||||
if (text.trim().length > 0) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps tmp scanning scoped to chat artifacts only.
|
||||
*/
|
||||
private shouldSkipTempArtifact(filePath: string): boolean {
|
||||
return (
|
||||
filePath.startsWith(path.join(this.geminiHome, 'tmp'))
|
||||
&& !filePath.includes(`${path.sep}chats${path.sep}`)
|
||||
);
|
||||
}
|
||||
|
||||
private sha256(value: string): string {
|
||||
return crypto.createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
}
|
||||
@@ -1,555 +0,0 @@
|
||||
import fsSync from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import readline from 'node:readline';
|
||||
|
||||
import { sessionsDb } from '@/modules/database/index.js';
|
||||
import { parseImagesInputTag } from '@/shared/image-attachments.js';
|
||||
import type { IProviderSessions } from '@/shared/interfaces.js';
|
||||
import type { AnyRecord, FetchHistoryOptions, FetchHistoryResult, NormalizedMessage } from '@/shared/types.js';
|
||||
import { createNormalizedMessage, generateMessageId, readObjectRecord, sliceTailPage } from '@/shared/utils.js';
|
||||
|
||||
const PROVIDER = 'gemini';
|
||||
|
||||
type GeminiHistoryResult = {
|
||||
messages: AnyRecord[];
|
||||
tokenUsage?: unknown;
|
||||
};
|
||||
|
||||
function mapGeminiRole(value: unknown): 'user' | 'assistant' | null {
|
||||
if (value === 'user') {
|
||||
return 'user';
|
||||
}
|
||||
|
||||
if (value === 'gemini' || value === 'assistant') {
|
||||
return 'assistant';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractGeminiTextContent(content: unknown): string {
|
||||
if (typeof content === 'string') {
|
||||
return content;
|
||||
}
|
||||
|
||||
if (!Array.isArray(content)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return content
|
||||
.map((part) => {
|
||||
if (typeof part === 'string') {
|
||||
return part;
|
||||
}
|
||||
if (!part || typeof part !== 'object') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const record = part as AnyRecord;
|
||||
if (typeof record.text === 'string') {
|
||||
return record.text;
|
||||
}
|
||||
|
||||
return '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function extractGeminiThoughts(thoughts: unknown): string {
|
||||
if (!Array.isArray(thoughts)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return thoughts
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const record = item as AnyRecord;
|
||||
const subject = typeof record.subject === 'string' ? record.subject.trim() : '';
|
||||
const description = typeof record.description === 'string' ? record.description.trim() : '';
|
||||
|
||||
if (subject && description) {
|
||||
return `${subject}: ${description}`;
|
||||
}
|
||||
|
||||
return description || subject;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function buildGeminiTokenUsage(tokens: unknown): AnyRecord | undefined {
|
||||
if (!tokens || typeof tokens !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const record = tokens as AnyRecord;
|
||||
const input = Number(record.input || 0);
|
||||
const output = Number(record.output || 0);
|
||||
const total = Number(record.total || input + output || 0);
|
||||
|
||||
return {
|
||||
used: total,
|
||||
inputTokens: input,
|
||||
outputTokens: output,
|
||||
breakdown: {
|
||||
input,
|
||||
output,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function getGeminiLegacySessionMessages(sessionFilePath: string): Promise<GeminiHistoryResult> {
|
||||
try {
|
||||
const data = await fs.readFile(sessionFilePath, 'utf8');
|
||||
const session = JSON.parse(data) as AnyRecord;
|
||||
const sourceMessages = Array.isArray(session.messages) ? session.messages : [];
|
||||
|
||||
const messages: AnyRecord[] = [];
|
||||
for (const msg of sourceMessages) {
|
||||
const role = mapGeminiRole(msg.type ?? msg.role);
|
||||
if (!role) {
|
||||
continue;
|
||||
}
|
||||
|
||||
messages.push({
|
||||
type: 'message',
|
||||
uuid: typeof msg.id === 'string' ? msg.id : undefined,
|
||||
message: { role, content: msg.content },
|
||||
timestamp: msg.timestamp || null,
|
||||
});
|
||||
}
|
||||
|
||||
return { messages };
|
||||
} catch {
|
||||
return { messages: [] };
|
||||
}
|
||||
}
|
||||
|
||||
// Exported for tests: parses one Gemini CLI JSONL transcript from disk.
|
||||
export async function getGeminiJsonlSessionMessages(sessionFilePath: string): Promise<GeminiHistoryResult> {
|
||||
const messages: AnyRecord[] = [];
|
||||
let tokenUsage: AnyRecord | undefined;
|
||||
|
||||
try {
|
||||
const fileStream = fsSync.createReadStream(sessionFilePath);
|
||||
const lineReader = readline.createInterface({
|
||||
input: fileStream,
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
|
||||
for await (const line of lineReader) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let entry: AnyRecord;
|
||||
try {
|
||||
entry = JSON.parse(trimmed) as AnyRecord;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Metadata/update lines (e.g. {$set:{lastUpdated:...}}) do not represent chat messages.
|
||||
if (entry.$set) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const role = mapGeminiRole(entry.type);
|
||||
if (role) {
|
||||
const rawTextContent = extractGeminiTextContent(entry.content);
|
||||
// User prompts sent with attachments carry an <images_input> path
|
||||
// list; strip it for display and surface the paths as images.
|
||||
const { text: textContent, attachments } = role === 'user'
|
||||
? parseImagesInputTag(rawTextContent)
|
||||
: { text: rawTextContent, attachments: [] };
|
||||
if (textContent.trim() || attachments.length > 0) {
|
||||
messages.push({
|
||||
type: 'message',
|
||||
uuid: typeof entry.id === 'string' ? entry.id : undefined,
|
||||
message: { role, content: textContent },
|
||||
images: attachments.length > 0 ? attachments : undefined,
|
||||
timestamp: entry.timestamp || null,
|
||||
});
|
||||
}
|
||||
|
||||
const thinkingContent = extractGeminiThoughts(entry.thoughts);
|
||||
if (thinkingContent.trim()) {
|
||||
messages.push({
|
||||
type: 'thinking',
|
||||
uuid: typeof entry.id === 'string' ? `${entry.id}_thinking` : undefined,
|
||||
message: { role: 'assistant', content: thinkingContent },
|
||||
timestamp: entry.timestamp || null,
|
||||
isReasoning: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (role === 'assistant') {
|
||||
const usage = buildGeminiTokenUsage(entry.tokens);
|
||||
if (usage) {
|
||||
tokenUsage = usage;
|
||||
}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.type === 'tool_use') {
|
||||
messages.push({
|
||||
type: 'tool_use',
|
||||
uuid: typeof entry.id === 'string' ? entry.id : undefined,
|
||||
timestamp: entry.timestamp || null,
|
||||
toolName: entry.tool_name || entry.name || 'Tool',
|
||||
toolInput: entry.parameters ?? entry.input ?? entry.arguments ?? '',
|
||||
toolCallId: entry.tool_id || entry.toolCallId || entry.id,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.type === 'tool_result') {
|
||||
messages.push({
|
||||
type: 'tool_result',
|
||||
uuid: typeof entry.id === 'string' ? entry.id : undefined,
|
||||
timestamp: entry.timestamp || null,
|
||||
toolCallId: entry.tool_id || entry.toolCallId || entry.id || '',
|
||||
output: entry.output ?? entry.result ?? '',
|
||||
isError: Boolean(entry.error) || entry.status === 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return { messages: [] };
|
||||
}
|
||||
|
||||
messages.sort(
|
||||
(a, b) => new Date(a.timestamp || 0).getTime() - new Date(b.timestamp || 0).getTime(),
|
||||
);
|
||||
|
||||
return { messages, tokenUsage };
|
||||
}
|
||||
|
||||
async function getGeminiCliSessionMessages(sessionId: string): Promise<GeminiHistoryResult> {
|
||||
const sessionFilePath = sessionsDb.getSessionById(sessionId)?.jsonl_path;
|
||||
if (!sessionFilePath) {
|
||||
return { messages: [] };
|
||||
}
|
||||
|
||||
if (sessionFilePath.endsWith('.jsonl')) {
|
||||
return getGeminiJsonlSessionMessages(sessionFilePath);
|
||||
}
|
||||
|
||||
return getGeminiLegacySessionMessages(sessionFilePath);
|
||||
}
|
||||
|
||||
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 result: GeminiHistoryResult;
|
||||
try {
|
||||
result = 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 rawMessages = result.messages;
|
||||
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');
|
||||
|
||||
if (raw.type === 'thinking' || raw.isReasoning) {
|
||||
const thinkingContent = typeof raw.message?.content === 'string'
|
||||
? raw.message.content
|
||||
: typeof raw.content === 'string'
|
||||
? raw.content
|
||||
: '';
|
||||
|
||||
if (thinkingContent.trim()) {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'thinking',
|
||||
content: thinkingContent,
|
||||
}));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (raw.type === 'tool_use' || raw.toolName) {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_use',
|
||||
toolName: raw.toolName || 'Tool',
|
||||
toolInput: raw.toolInput,
|
||||
toolId: raw.toolCallId || baseId,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (raw.type === 'tool_result') {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'tool_result',
|
||||
toolId: raw.toolCallId || '',
|
||||
content: raw.output === undefined ? '' : String(raw.output),
|
||||
isError: Boolean(raw.isError),
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
const role = raw.message?.role || raw.role;
|
||||
const content = raw.message?.content ?? raw.content ?? '';
|
||||
const rawImages = Array.isArray(raw.images) && raw.images.length > 0 ? raw.images : undefined;
|
||||
if (!role || (!content && !rawImages)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const normalizedRole = role === 'user' ? 'user' : 'assistant';
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
for (let partIdx = 0; partIdx < content.length; partIdx++) {
|
||||
const part = content[partIdx] as AnyRecord | string;
|
||||
|
||||
if (typeof part === 'string' && part.trim()) {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: `${baseId}_${partIdx}`,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: normalizedRole,
|
||||
content: part,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!part || typeof part !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((part.type === 'text' || !part.type) && typeof part.text === 'string' && part.text.trim()) {
|
||||
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() || rawImages)) {
|
||||
// Legacy (non-JSONL) session files reach this branch with the raw
|
||||
// prompt text, so strip any <images_input> block here as well.
|
||||
const { text: cleanContent, attachments } = normalizedRole === 'user'
|
||||
? parseImagesInputTag(content)
|
||||
: { text: content, attachments: [] };
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: normalizedRole,
|
||||
content: cleanContent,
|
||||
images: rawImages ?? (attachments.length > 0 ? attachments : undefined),
|
||||
}));
|
||||
} else {
|
||||
const textContent = extractGeminiTextContent(content);
|
||||
if (textContent.trim()) {
|
||||
normalized.push(createNormalizedMessage({
|
||||
id: baseId,
|
||||
sessionId,
|
||||
timestamp: ts,
|
||||
provider: PROVIDER,
|
||||
kind: 'text',
|
||||
role: normalizedRole,
|
||||
content: textContent,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
// Tail pagination via the shared contract: offset 0 returns the most
|
||||
// recent page, matching every other provider.
|
||||
const { page, hasMore } = sliceTailPage(normalized, pageLimit, start);
|
||||
let total = 0;
|
||||
for (const msg of normalized) {
|
||||
if (msg.kind !== 'tool_result') {
|
||||
total += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
messages: page,
|
||||
total,
|
||||
hasMore,
|
||||
offset: start,
|
||||
limit: pageLimit,
|
||||
tokenUsage: result.tokenUsage,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { SkillsProvider } from '@/modules/providers/shared/skills/skills.provider.js';
|
||||
import type { ProviderSkillSource } from '@/shared/types.js';
|
||||
|
||||
export class GeminiSkillsProvider extends SkillsProvider {
|
||||
constructor() {
|
||||
super('gemini');
|
||||
}
|
||||
|
||||
protected async getSkillSources(workspacePath: string): Promise<ProviderSkillSource[]> {
|
||||
return [
|
||||
{
|
||||
scope: 'user',
|
||||
rootDir: path.join(os.homedir(), '.gemini', 'skills'),
|
||||
commandPrefix: '/',
|
||||
},
|
||||
{
|
||||
scope: 'user',
|
||||
rootDir: path.join(os.homedir(), '.agents', 'skills'),
|
||||
commandPrefix: '/',
|
||||
},
|
||||
{
|
||||
scope: 'project',
|
||||
rootDir: path.join(workspacePath, '.gemini', 'skills'),
|
||||
commandPrefix: '/',
|
||||
},
|
||||
{
|
||||
scope: 'project',
|
||||
rootDir: path.join(workspacePath, '.agents', 'skills'),
|
||||
commandPrefix: '/',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
protected async getGlobalSkillSource(): Promise<ProviderSkillSource> {
|
||||
return {
|
||||
scope: 'user',
|
||||
rootDir: path.join(os.homedir(), '.gemini', 'skills'),
|
||||
commandPrefix: '/',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { AbstractProvider } from '@/modules/providers/shared/base/abstract.provider.js';
|
||||
import { GeminiProviderAuth } from '@/modules/providers/list/gemini/gemini-auth.provider.js';
|
||||
import { GeminiProviderModels } from '@/modules/providers/list/gemini/gemini-models.provider.js';
|
||||
import { GeminiMcpProvider } from '@/modules/providers/list/gemini/gemini-mcp.provider.js';
|
||||
import { GeminiSessionSynchronizer } from '@/modules/providers/list/gemini/gemini-session-synchronizer.provider.js';
|
||||
import { GeminiSessionsProvider } from '@/modules/providers/list/gemini/gemini-sessions.provider.js';
|
||||
import { GeminiSkillsProvider } from '@/modules/providers/list/gemini/gemini-skills.provider.js';
|
||||
import type {
|
||||
IProviderAuth,
|
||||
IProviderModels,
|
||||
IProviderSessionSynchronizer,
|
||||
IProviderSkills,
|
||||
IProviderSessions,
|
||||
} from '@/shared/interfaces.js';
|
||||
|
||||
export class GeminiProvider extends AbstractProvider {
|
||||
readonly models: IProviderModels = new GeminiProviderModels();
|
||||
readonly mcp = new GeminiMcpProvider();
|
||||
readonly auth: IProviderAuth = new GeminiProviderAuth();
|
||||
readonly skills: IProviderSkills = new GeminiSkillsProvider();
|
||||
readonly sessions: IProviderSessions = new GeminiSessionsProvider();
|
||||
readonly sessionSynchronizer: IProviderSessionSynchronizer = new GeminiSessionSynchronizer();
|
||||
|
||||
constructor() {
|
||||
super('gemini');
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,6 @@ const OPENCODE_ENV_CREDENTIAL_KEYS = [
|
||||
'ANTHROPIC_API_KEY',
|
||||
'OPENAI_API_KEY',
|
||||
'GOOGLE_GENERATIVE_AI_API_KEY',
|
||||
'GEMINI_API_KEY',
|
||||
'GROQ_API_KEY',
|
||||
'OPENROUTER_API_KEY',
|
||||
];
|
||||
|
||||
@@ -49,16 +49,6 @@ export const OPENCODE_FALLBACK_MODELS: ProviderModelsDefinition = {
|
||||
label: 'GPT-5.4 Mini',
|
||||
description: 'openai - openai/gpt-5.4-mini',
|
||||
},
|
||||
{
|
||||
value: 'google/gemini-2.5-pro',
|
||||
label: 'Gemini 2.5 Pro',
|
||||
description: 'google - google/gemini-2.5-pro',
|
||||
},
|
||||
{
|
||||
value: 'google/gemini-2.5-flash',
|
||||
label: 'Gemini 2.5 Flash',
|
||||
description: 'google - google/gemini-2.5-flash',
|
||||
},
|
||||
],
|
||||
DEFAULT: 'anthropic/claude-sonnet-4-5',
|
||||
};
|
||||
@@ -239,6 +229,10 @@ const readOpenCodeModelParts = (id: string): { upstreamProvider: string; slug: s
|
||||
};
|
||||
};
|
||||
|
||||
const isSupportedOpenCodeModelId = (id: string): boolean => (
|
||||
readOpenCodeModelParts(id).upstreamProvider.toLowerCase() !== 'google'
|
||||
);
|
||||
|
||||
const readOpenCodeVerboseModelId = (model: OpenCodeVerboseModel): string | null => {
|
||||
const id = readOptionalString(model.id);
|
||||
if (!id) {
|
||||
@@ -296,7 +290,7 @@ const readOpenCodeEffortValues = (
|
||||
|
||||
const mapOpenCodeVerboseModel = (model: OpenCodeVerboseModel): ProviderModelOption | null => {
|
||||
const value = readOpenCodeVerboseModelId(model);
|
||||
if (!value) {
|
||||
if (!value || !isSupportedOpenCodeModelId(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -315,11 +309,13 @@ const mapOpenCodeVerboseModel = (model: OpenCodeVerboseModel): ProviderModelOpti
|
||||
};
|
||||
|
||||
export const buildOpenCodeDefinitionFromIds = (ids: string[]): ProviderModelsDefinition => {
|
||||
const options: ProviderModelOption[] = ids.map((value) => ({
|
||||
value,
|
||||
label: labelForOpenCodeModelId(value),
|
||||
description: descriptionForOpenCodeModelId(value),
|
||||
}));
|
||||
const options: ProviderModelOption[] = ids
|
||||
.filter(isSupportedOpenCodeModelId)
|
||||
.map((value) => ({
|
||||
value,
|
||||
label: labelForOpenCodeModelId(value),
|
||||
description: descriptionForOpenCodeModelId(value),
|
||||
}));
|
||||
|
||||
const defaultValue = options.find((option) => option.value === OPENCODE_FALLBACK_MODELS.DEFAULT)?.value
|
||||
?? options[0]?.value
|
||||
|
||||
Reference in New Issue
Block a user