feat: implement Gemini OAuth 2.0 flow with automated credential loading and improve prompt language handling.
This commit is contained in:
parent
ddc1071810
commit
98fb5d3137
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -46,3 +46,5 @@ data
|
||||||
!.codex/prompts/**
|
!.codex/prompts/**
|
||||||
teach-me
|
teach-me
|
||||||
credentials.json
|
credentials.json
|
||||||
|
OAuth.json
|
||||||
|
.claude/agents/hello-agent.md
|
||||||
|
|
|
||||||
17
README.md
17
README.md
|
|
@ -150,24 +150,17 @@ bun run build
|
||||||
|
|
||||||
1. **Anthropic Compatible**: 对接第三方 API 兼容服务(OpenRouter、AWS Bedrock 代理等)。
|
1. **Anthropic Compatible**: 对接第三方 API 兼容服务(OpenRouter、AWS Bedrock 代理等)。
|
||||||
2. **OpenAI / Gemini / Grok**: 对应各自协议的云端服务。
|
2. **OpenAI / Gemini / Grok**: 对应各自协议的云端服务。
|
||||||
|
- **Gemini (Google Auth)**: 支持交互式浏览器登录。
|
||||||
|
1. 在 Google Cloud Console 的 **APIs & Services > OAuth consent screen** 中配置 OAuth 客户端(User Type 设为 External)。
|
||||||
|
2. 下载 credentials JSON 文件并保存到项目根目录的 `/.files/OAuth.json`。
|
||||||
|
3. 在 `/login` 配置界面中留空 API Key 并按 Enter,程序将自动拉起浏览器完成授权并自动拉取模型列表。
|
||||||
3. **Local LLM**: **(推荐)** 使用本地运行的模型。
|
3. **Local LLM**: **(推荐)** 使用本地运行的模型。
|
||||||
- 支持 **Ollama**, **LM Studio**, **Jan.ai**, **LocalAI**。
|
- 支持 **Ollama**, **LM Studio**, **Jan.ai**, **LocalAI**。
|
||||||
- **Ollama 深度集成**: 可直接在 CLI 中查看已安装模型,或输入模型名(如 `llama3.1`)一键拉取(Pull)。
|
- **Ollama 深度集成**: 可直接在 CLI 中查看已安装模型,或输入模型名(如 `llama3.1`)一键拉取(Pull)。支持模型列表交互切换和硬件状态自动检测。
|
||||||
- 自动检测本地运行状态和默认端口。
|
- 自动检测本地运行状态和默认端口。
|
||||||
|
|
||||||
#### /login 字段说明 (云端模式):
|
#### /login 字段说明 (云端模式):
|
||||||
|
|
||||||
|
|
||||||
| 📌 字段 | 📝 说明 | 💡 示例 |
|
|
||||||
| ------------ | ------------- | ---------------------------- |
|
|
||||||
| Base URL | API 服务地址 | `https://api.example.com/v1` |
|
|
||||||
| API Key | 认证密钥 | `sk-xxx` |
|
|
||||||
| Haiku Model | 快速模型 ID | `claude-haiku-4-5-20251001` |
|
|
||||||
| Sonnet Model | 均衡模型 ID | `claude-sonnet-4-6` |
|
|
||||||
| Opus Model | 高性能模型 ID | `claude-opus-4-6` |
|
|
||||||
|
|
||||||
- ⌨️ **Tab / Shift+Tab** 切换字段,**Enter** 确认并跳到下一个,最后一个字段按 Enter 保存
|
|
||||||
|
|
||||||
> ℹ️ 支持所有 Anthropic API 兼容服务(如 OpenRouter、AWS Bedrock 代理等),只要接口兼容 Messages API 即可。
|
> ℹ️ 支持所有 Anthropic API 兼容服务(如 OpenRouter、AWS Bedrock 代理等),只要接口兼容 Messages API 即可。
|
||||||
|
|
||||||
### 🩺 系统诊断 /doctor
|
### 🩺 系统诊断 /doctor
|
||||||
|
|
|
||||||
24
README_EN.md
24
README_EN.md
|
|
@ -143,20 +143,18 @@ If you encounter a bug, please open an issue; we prioritize solving them.
|
||||||
|
|
||||||
### 👤 New User Configuration /login
|
### 👤 New User Configuration /login
|
||||||
|
|
||||||
After running for the first time, type `/login` in the REPL to enter the login configuration interface. Select **Anthropic Compatible** to connect to third-party API services (no official Anthropic account required).
|
After running for the first time, type `/login` in the REPL to enter the login configuration interface.
|
||||||
Options for OpenAI and Gemini are also available for their respective protocols.
|
|
||||||
|
|
||||||
Fields to fill in:
|
1. **Anthropic Compatible**: Connect to third-party API services (OpenRouter, AWS Bedrock proxies, etc.) (no official Anthropic account required).
|
||||||
|
2. **OpenAI / Gemini / Grok**: Connect to cloud services using their respective protocols.
|
||||||
| 📌 Field | 📝 Description | 💡 Example |
|
- **Gemini (Google Auth)**: Supports interactive browser login.
|
||||||
| --- | --- | --- |
|
1. In the Google Cloud Console, navigate to **APIs & Services > OAuth consent screen** and configure the OAuth client (Set User Type to External).
|
||||||
| Base URL | API Service Address | `https://api.example.com/v1` |
|
2. Download the credentials JSON format and save it as `/.files/OAuth.json` in the project root.
|
||||||
| API Key | Authentication Key | `sk-xxx` |
|
3. Leave the API Key field blank and press Enter in the `/login` configuration interface; the CLI will automatically open your browser for Google OAuth 2.0 authorization and fetch available models.
|
||||||
| Haiku Model | Fast Model ID | `claude-haiku-4-5-20251001` |
|
3. **Local LLM**: **(Recommended)** Use models running locally.
|
||||||
| Sonnet Model | Balanced Model ID | `claude-sonnet-4-6` |
|
- Supports **Ollama**, **LM Studio**, **Jan.ai**, **LocalAI**.
|
||||||
| Opus Model | High Performance Model ID | `claude-opus-4-6` |
|
- **Ollama Deep Integration**: View installed models directly in the CLI, or enter a model name (e.g., `llama3.1`) to pull it instantly. Supports interactive model selection navigation and hardware status auto-detection.
|
||||||
|
- Automatically detects local runner status and default ports.
|
||||||
- ⌨️ **Tab / Shift+Tab** to switch fields, **Enter** to confirm and jump to the next, press Enter on the last field to save.
|
|
||||||
|
|
||||||
> ℹ️ Supports all Anthropic API compatible services (e.g., OpenRouter, AWS Bedrock proxies, etc.), as long as the interface is compatible with the Messages API.
|
> ℹ️ Supports all Anthropic API compatible services (e.g., OpenRouter, AWS Bedrock proxies, etc.), as long as the interface is compatible with the Messages API.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -49,12 +49,21 @@ type OAuthStatus =
|
||||||
} // OpenAI Chat Completions API platform
|
} // OpenAI Chat Completions API platform
|
||||||
| {
|
| {
|
||||||
state: 'gemini_api';
|
state: 'gemini_api';
|
||||||
baseUrl: string;
|
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
haikuModel: string;
|
haikuModel: string;
|
||||||
sonnetModel: string;
|
sonnetModel: string;
|
||||||
opusModel: string;
|
opusModel: string;
|
||||||
activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model';
|
activeField:
|
||||||
|
| 'api_key'
|
||||||
|
| 'haiku_model'
|
||||||
|
| 'sonnet_model'
|
||||||
|
| 'opus_model'
|
||||||
|
| 'custom_haiku_model'
|
||||||
|
| 'custom_sonnet_model'
|
||||||
|
| 'custom_opus_model';
|
||||||
|
availableModels: string[];
|
||||||
|
isLoadingModels: boolean;
|
||||||
|
statusMessage?: string;
|
||||||
} // Gemini Generate Content API platform
|
} // Gemini Generate Content API platform
|
||||||
| {
|
| {
|
||||||
state: 'local_llm_setup';
|
state: 'local_llm_setup';
|
||||||
|
|
@ -152,7 +161,8 @@ export function ConsoleOAuthFlow({
|
||||||
oauthStatus.state === 'local_llm_setup' &&
|
oauthStatus.state === 'local_llm_setup' &&
|
||||||
oauthStatus.runnerType === 'ollama' &&
|
oauthStatus.runnerType === 'ollama' &&
|
||||||
oauthStatus.availableModels.length === 0 &&
|
oauthStatus.availableModels.length === 0 &&
|
||||||
!oauthStatus.isLoadingModels
|
!oauthStatus.isLoadingModels &&
|
||||||
|
!oauthStatus.statusMessage
|
||||||
) {
|
) {
|
||||||
setOAuthStatus(prev => (prev.state === 'local_llm_setup' ? { ...prev, isLoadingModels: true } : prev));
|
setOAuthStatus(prev => (prev.state === 'local_llm_setup' ? { ...prev, isLoadingModels: true } : prev));
|
||||||
listOllamaModels(oauthStatus.baseUrl)
|
listOllamaModels(oauthStatus.baseUrl)
|
||||||
|
|
@ -499,7 +509,7 @@ type OAuthStatusMessageProps = {
|
||||||
setCursorOffset: (offset: number) => void;
|
setCursorOffset: (offset: number) => void;
|
||||||
textInputColumns: number;
|
textInputColumns: number;
|
||||||
handleSubmitCode: (value: string, url: string) => void;
|
handleSubmitCode: (value: string, url: string) => void;
|
||||||
setOAuthStatus: (status: OAuthStatus) => void;
|
setOAuthStatus: React.Dispatch<React.SetStateAction<OAuthStatus>>;
|
||||||
setLoginWithClaudeAi: (value: boolean) => void;
|
setLoginWithClaudeAi: (value: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -648,12 +658,13 @@ function OAuthStatusMessage({
|
||||||
logEvent('tengu_gemini_api_selected', {});
|
logEvent('tengu_gemini_api_selected', {});
|
||||||
setOAuthStatus({
|
setOAuthStatus({
|
||||||
state: 'gemini_api',
|
state: 'gemini_api',
|
||||||
baseUrl: process.env.GEMINI_BASE_URL ?? '',
|
|
||||||
apiKey: process.env.GEMINI_API_KEY ?? '',
|
apiKey: process.env.GEMINI_API_KEY ?? '',
|
||||||
haikuModel: process.env.GEMINI_DEFAULT_HAIKU_MODEL ?? '',
|
haikuModel: process.env.GEMINI_DEFAULT_HAIKU_MODEL ?? '',
|
||||||
sonnetModel: process.env.GEMINI_DEFAULT_SONNET_MODEL ?? '',
|
sonnetModel: process.env.GEMINI_DEFAULT_SONNET_MODEL ?? '',
|
||||||
opusModel: process.env.GEMINI_DEFAULT_OPUS_MODEL ?? '',
|
opusModel: process.env.GEMINI_DEFAULT_OPUS_MODEL ?? '',
|
||||||
activeField: 'base_url',
|
activeField: 'api_key',
|
||||||
|
availableModels: [],
|
||||||
|
isLoadingModels: false,
|
||||||
});
|
});
|
||||||
} else if (value === 'platform') {
|
} else if (value === 'platform') {
|
||||||
logEvent('tengu_oauth_platform_selected', {});
|
logEvent('tengu_oauth_platform_selected', {});
|
||||||
|
|
@ -1406,29 +1417,49 @@ function OAuthStatusMessage({
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'gemini_api': {
|
case 'gemini_api': {
|
||||||
type GeminiField = 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model';
|
type GeminiField =
|
||||||
const GEMINI_FIELDS: GeminiField[] = ['base_url', 'api_key', 'haiku_model', 'sonnet_model', 'opus_model'];
|
| 'api_key'
|
||||||
|
| 'haiku_model'
|
||||||
|
| 'sonnet_model'
|
||||||
|
| 'opus_model'
|
||||||
|
| 'custom_haiku_model'
|
||||||
|
| 'custom_sonnet_model'
|
||||||
|
| 'custom_opus_model';
|
||||||
|
const GEMINI_FIELDS: GeminiField[] = ['api_key', 'haiku_model', 'sonnet_model', 'opus_model'];
|
||||||
const gp = oauthStatus as {
|
const gp = oauthStatus as {
|
||||||
state: 'gemini_api';
|
state: 'gemini_api';
|
||||||
activeField: GeminiField;
|
activeField: GeminiField;
|
||||||
baseUrl: string;
|
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
haikuModel: string;
|
haikuModel: string;
|
||||||
sonnetModel: string;
|
sonnetModel: string;
|
||||||
opusModel: string;
|
opusModel: string;
|
||||||
|
availableModels: string[];
|
||||||
|
isLoadingModels: boolean;
|
||||||
|
statusMessage?: string;
|
||||||
};
|
};
|
||||||
const { activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel } = gp;
|
const {
|
||||||
const geminiDisplayValues: Record<GeminiField, string> = {
|
activeField,
|
||||||
base_url: baseUrl,
|
apiKey,
|
||||||
|
haikuModel,
|
||||||
|
sonnetModel,
|
||||||
|
opusModel,
|
||||||
|
availableModels,
|
||||||
|
isLoadingModels,
|
||||||
|
statusMessage,
|
||||||
|
} = gp;
|
||||||
|
const geminiDisplayValues: Record<string, string> = {
|
||||||
api_key: apiKey,
|
api_key: apiKey,
|
||||||
haiku_model: haikuModel,
|
haiku_model: haikuModel,
|
||||||
sonnet_model: sonnetModel,
|
sonnet_model: sonnetModel,
|
||||||
opus_model: opusModel,
|
opus_model: opusModel,
|
||||||
|
custom_haiku_model: haikuModel,
|
||||||
|
custom_sonnet_model: sonnetModel,
|
||||||
|
custom_opus_model: opusModel,
|
||||||
};
|
};
|
||||||
|
|
||||||
const [geminiInputValue, setGeminiInputValue] = useState(() => geminiDisplayValues[activeField]);
|
const [geminiInputValue, setGeminiInputValue] = useState(() => geminiDisplayValues[activeField] ?? '');
|
||||||
const [geminiInputCursorOffset, setGeminiInputCursorOffset] = useState(
|
const [geminiInputCursorOffset, setGeminiInputCursorOffset] = useState(
|
||||||
() => geminiDisplayValues[activeField].length,
|
() => (geminiDisplayValues[activeField] ?? '').length,
|
||||||
);
|
);
|
||||||
|
|
||||||
const buildGeminiState = useCallback(
|
const buildGeminiState = useCallback(
|
||||||
|
|
@ -1436,137 +1467,206 @@ function OAuthStatusMessage({
|
||||||
const s = {
|
const s = {
|
||||||
state: 'gemini_api' as const,
|
state: 'gemini_api' as const,
|
||||||
activeField: newActive ?? activeField,
|
activeField: newActive ?? activeField,
|
||||||
baseUrl,
|
|
||||||
apiKey,
|
apiKey,
|
||||||
haikuModel,
|
haikuModel,
|
||||||
sonnetModel,
|
sonnetModel,
|
||||||
opusModel,
|
opusModel,
|
||||||
|
availableModels,
|
||||||
|
isLoadingModels,
|
||||||
|
statusMessage,
|
||||||
};
|
};
|
||||||
switch (field) {
|
switch (field) {
|
||||||
case 'base_url':
|
|
||||||
return { ...s, baseUrl: value };
|
|
||||||
case 'api_key':
|
case 'api_key':
|
||||||
return { ...s, apiKey: value };
|
return { ...s, apiKey: value };
|
||||||
case 'haiku_model':
|
case 'haiku_model':
|
||||||
|
case 'custom_haiku_model':
|
||||||
return { ...s, haikuModel: value };
|
return { ...s, haikuModel: value };
|
||||||
case 'sonnet_model':
|
case 'sonnet_model':
|
||||||
|
case 'custom_sonnet_model':
|
||||||
return { ...s, sonnetModel: value };
|
return { ...s, sonnetModel: value };
|
||||||
case 'opus_model':
|
case 'opus_model':
|
||||||
|
case 'custom_opus_model':
|
||||||
return { ...s, opusModel: value };
|
return { ...s, opusModel: value };
|
||||||
|
default:
|
||||||
|
return s;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel],
|
[activeField, apiKey, haikuModel, sonnetModel, opusModel, availableModels, isLoadingModels, statusMessage],
|
||||||
);
|
);
|
||||||
|
|
||||||
const doGeminiSave = useCallback(async () => {
|
const fetchGeminiModels = useCallback(
|
||||||
const finalVals = { ...geminiDisplayValues, [activeField]: geminiInputValue };
|
async (currentApiKey: string) => {
|
||||||
if (!finalVals.haiku_model || !finalVals.sonnet_model || !finalVals.opus_model) {
|
setOAuthStatus((prev: OAuthStatus) =>
|
||||||
setOAuthStatus({
|
prev.state === 'gemini_api'
|
||||||
state: 'error',
|
? {
|
||||||
message: 'Gemini setup requires Haiku, Sonnet, and Opus model names.',
|
...prev,
|
||||||
toRetry: {
|
isLoadingModels: true,
|
||||||
state: 'gemini_api',
|
statusMessage: currentApiKey ? 'Fetching models...' : 'Authenticating via browser...',
|
||||||
baseUrl: finalVals.base_url,
|
}
|
||||||
apiKey: finalVals.api_key,
|
: prev,
|
||||||
haikuModel: finalVals.haiku_model,
|
);
|
||||||
sonnetModel: finalVals.sonnet_model,
|
|
||||||
opusModel: finalVals.opus_model,
|
|
||||||
activeField,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!finalVals.api_key) {
|
|
||||||
setOAuthStatus({ state: 'waiting_for_login' });
|
|
||||||
try {
|
try {
|
||||||
const { loginToGoogle } = await import('src/services/api/gemini/google-oauth.js');
|
if (!currentApiKey) {
|
||||||
await loginToGoogle();
|
const { loginToGoogle } = await import('src/services/api/gemini/google-oauth.js');
|
||||||
|
await loginToGoogle();
|
||||||
|
}
|
||||||
|
const { listGeminiModels } = await import('src/services/api/gemini/client.js');
|
||||||
|
const models = await listGeminiModels(currentApiKey || undefined);
|
||||||
|
setOAuthStatus((prev: OAuthStatus) =>
|
||||||
|
prev.state === 'gemini_api'
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
availableModels: models,
|
||||||
|
isLoadingModels: false,
|
||||||
|
statusMessage: undefined,
|
||||||
|
activeField: 'haiku_model',
|
||||||
|
}
|
||||||
|
: prev,
|
||||||
|
);
|
||||||
|
setGeminiInputValue(geminiDisplayValues['haiku_model'] ?? '');
|
||||||
|
setGeminiInputCursorOffset((geminiDisplayValues['haiku_model'] ?? '').length);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
setOAuthStatus((prev: OAuthStatus) =>
|
||||||
|
prev.state === 'gemini_api'
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
isLoadingModels: false,
|
||||||
|
statusMessage: undefined,
|
||||||
|
}
|
||||||
|
: prev,
|
||||||
|
);
|
||||||
setOAuthStatus({
|
setOAuthStatus({
|
||||||
state: 'error',
|
state: 'error',
|
||||||
message: `Google Login failed: ${e instanceof Error ? e.message : e}`,
|
message: `Failed to fetch models: ${e instanceof Error ? e.message : e}`,
|
||||||
toRetry: {
|
toRetry: {
|
||||||
state: 'gemini_api',
|
state: 'gemini_api',
|
||||||
baseUrl: finalVals.base_url,
|
apiKey: currentApiKey,
|
||||||
apiKey: finalVals.api_key,
|
haikuModel,
|
||||||
haikuModel: finalVals.haiku_model,
|
sonnetModel,
|
||||||
sonnetModel: finalVals.sonnet_model,
|
opusModel,
|
||||||
opusModel: finalVals.opus_model,
|
|
||||||
activeField: 'api_key',
|
activeField: 'api_key',
|
||||||
|
availableModels: [],
|
||||||
|
isLoadingModels: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[haikuModel, sonnetModel, opusModel, geminiDisplayValues, setOAuthStatus],
|
||||||
|
);
|
||||||
|
|
||||||
|
const doGeminiSave = useCallback(
|
||||||
|
async (stateToSave: any) => {
|
||||||
|
const {
|
||||||
|
apiKey: finalApiKey,
|
||||||
|
haikuModel: finalHaiku,
|
||||||
|
sonnetModel: finalSonnet,
|
||||||
|
opusModel: finalOpus,
|
||||||
|
} = stateToSave;
|
||||||
|
if (!finalHaiku || !finalSonnet || !finalOpus) {
|
||||||
|
setOAuthStatus({
|
||||||
|
state: 'error',
|
||||||
|
message: 'Gemini setup requires Haiku, Sonnet, and Opus model names.',
|
||||||
|
toRetry: {
|
||||||
|
...stateToSave,
|
||||||
|
activeField,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const env: Record<string, string> = {};
|
const env: Record<string, string> = {};
|
||||||
if (finalVals.base_url) env.GEMINI_BASE_URL = finalVals.base_url;
|
if (finalApiKey) env.GEMINI_API_KEY = finalApiKey;
|
||||||
if (finalVals.api_key) env.GEMINI_API_KEY = finalVals.api_key;
|
if (finalHaiku) env.GEMINI_DEFAULT_HAIKU_MODEL = finalHaiku;
|
||||||
if (finalVals.haiku_model) env.GEMINI_DEFAULT_HAIKU_MODEL = finalVals.haiku_model;
|
if (finalSonnet) env.GEMINI_DEFAULT_SONNET_MODEL = finalSonnet;
|
||||||
if (finalVals.sonnet_model) env.GEMINI_DEFAULT_SONNET_MODEL = finalVals.sonnet_model;
|
if (finalOpus) env.GEMINI_DEFAULT_OPUS_MODEL = finalOpus;
|
||||||
if (finalVals.opus_model) env.GEMINI_DEFAULT_OPUS_MODEL = finalVals.opus_model;
|
const { error } = updateSettingsForSource('userSettings', {
|
||||||
const { error } = updateSettingsForSource('userSettings', {
|
modelType: 'gemini' as any,
|
||||||
modelType: 'gemini' as any,
|
env,
|
||||||
env,
|
} as any);
|
||||||
} as any);
|
if (error) {
|
||||||
if (error) {
|
setOAuthStatus({
|
||||||
setOAuthStatus({
|
state: 'error',
|
||||||
state: 'error',
|
message: `Failed to save: ${error.message}`,
|
||||||
message: `Failed to save: ${error.message}`,
|
toRetry: {
|
||||||
toRetry: {
|
...stateToSave,
|
||||||
state: 'gemini_api',
|
activeField: 'api_key',
|
||||||
baseUrl: '',
|
},
|
||||||
apiKey: '',
|
});
|
||||||
haikuModel: '',
|
} else {
|
||||||
sonnetModel: '',
|
for (const [k, v] of Object.entries(env)) process.env[k] = v;
|
||||||
opusModel: '',
|
setOAuthStatus({ state: 'success' });
|
||||||
activeField: 'base_url',
|
void onDone();
|
||||||
},
|
}
|
||||||
});
|
},
|
||||||
} else {
|
[activeField, onDone, setOAuthStatus],
|
||||||
for (const [k, v] of Object.entries(env)) process.env[k] = v;
|
);
|
||||||
setOAuthStatus({ state: 'success' });
|
|
||||||
void onDone();
|
|
||||||
}
|
|
||||||
}, [activeField, geminiInputValue, geminiDisplayValues, onDone, setOAuthStatus]);
|
|
||||||
|
|
||||||
const handleGeminiEnter = useCallback(() => {
|
const handleGeminiEnter = useCallback(() => {
|
||||||
const idx = GEMINI_FIELDS.indexOf(activeField);
|
if (activeField.startsWith('custom_') && geminiInputValue) {
|
||||||
|
const nextState = buildGeminiState(activeField, geminiInputValue);
|
||||||
|
setOAuthStatus(nextState);
|
||||||
|
doGeminiSave(nextState);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeField === 'api_key') {
|
||||||
|
const nextState = buildGeminiState(activeField, geminiInputValue);
|
||||||
|
setOAuthStatus(nextState);
|
||||||
|
void fetchGeminiModels(geminiInputValue);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idx = GEMINI_FIELDS.indexOf(activeField as any);
|
||||||
if (idx === GEMINI_FIELDS.length - 1) {
|
if (idx === GEMINI_FIELDS.length - 1) {
|
||||||
setOAuthStatus(buildGeminiState(activeField, geminiInputValue));
|
const nextState = buildGeminiState(activeField, geminiInputValue);
|
||||||
doGeminiSave();
|
setOAuthStatus(nextState);
|
||||||
|
doGeminiSave(nextState);
|
||||||
} else {
|
} else {
|
||||||
const next = GEMINI_FIELDS[idx + 1]!;
|
const next = GEMINI_FIELDS[idx + 1]!;
|
||||||
setOAuthStatus(buildGeminiState(activeField, geminiInputValue, next));
|
const nextState = buildGeminiState(activeField, geminiInputValue, next);
|
||||||
|
setOAuthStatus(nextState);
|
||||||
setGeminiInputValue(geminiDisplayValues[next] ?? '');
|
setGeminiInputValue(geminiDisplayValues[next] ?? '');
|
||||||
setGeminiInputCursorOffset((geminiDisplayValues[next] ?? '').length);
|
setGeminiInputCursorOffset((geminiDisplayValues[next] ?? '').length);
|
||||||
}
|
}
|
||||||
}, [activeField, buildGeminiState, doGeminiSave, geminiDisplayValues, geminiInputValue, setOAuthStatus]);
|
}, [
|
||||||
|
activeField,
|
||||||
|
buildGeminiState,
|
||||||
|
doGeminiSave,
|
||||||
|
fetchGeminiModels,
|
||||||
|
geminiDisplayValues,
|
||||||
|
geminiInputValue,
|
||||||
|
setOAuthStatus,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const isTextInputActive = activeField === 'api_key' || activeField.startsWith('custom_');
|
||||||
|
|
||||||
useKeybinding(
|
useKeybinding(
|
||||||
'tabs:next',
|
'tabs:next',
|
||||||
() => {
|
() => {
|
||||||
const idx = GEMINI_FIELDS.indexOf(activeField);
|
const idx = GEMINI_FIELDS.indexOf(activeField as any);
|
||||||
if (idx < GEMINI_FIELDS.length - 1) {
|
if (idx < GEMINI_FIELDS.length - 1) {
|
||||||
setOAuthStatus(buildGeminiState(activeField, geminiInputValue, GEMINI_FIELDS[idx + 1]));
|
const next = GEMINI_FIELDS[idx + 1]!;
|
||||||
setGeminiInputValue(geminiDisplayValues[GEMINI_FIELDS[idx + 1]!] ?? '');
|
const nextState = buildGeminiState(activeField, geminiInputValue, next);
|
||||||
setGeminiInputCursorOffset((geminiDisplayValues[GEMINI_FIELDS[idx + 1]!] ?? '').length);
|
setOAuthStatus(nextState);
|
||||||
|
setGeminiInputValue(geminiDisplayValues[next] ?? '');
|
||||||
|
setGeminiInputCursorOffset((geminiDisplayValues[next] ?? '').length);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ context: 'FormField' },
|
{ context: 'FormField', isActive: isTextInputActive },
|
||||||
);
|
);
|
||||||
useKeybinding(
|
useKeybinding(
|
||||||
'tabs:previous',
|
'tabs:previous',
|
||||||
() => {
|
() => {
|
||||||
const idx = GEMINI_FIELDS.indexOf(activeField);
|
const idx = GEMINI_FIELDS.indexOf(activeField as any);
|
||||||
if (idx > 0) {
|
if (idx > 0) {
|
||||||
setOAuthStatus(buildGeminiState(activeField, geminiInputValue, GEMINI_FIELDS[idx - 1]));
|
const prev = GEMINI_FIELDS[idx - 1]!;
|
||||||
setGeminiInputValue(geminiDisplayValues[GEMINI_FIELDS[idx - 1]!] ?? '');
|
const nextState = buildGeminiState(activeField, geminiInputValue, prev);
|
||||||
setGeminiInputCursorOffset((geminiDisplayValues[GEMINI_FIELDS[idx - 1]!] ?? '').length);
|
setOAuthStatus(nextState);
|
||||||
|
setGeminiInputValue(geminiDisplayValues[prev] ?? '');
|
||||||
|
setGeminiInputCursorOffset((geminiDisplayValues[prev] ?? '').length);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ context: 'FormField' },
|
{ context: 'FormField', isActive: isTextInputActive },
|
||||||
);
|
);
|
||||||
useKeybinding(
|
useKeybinding(
|
||||||
'confirm:no',
|
'confirm:no',
|
||||||
|
|
@ -1578,6 +1678,67 @@ function OAuthStatusMessage({
|
||||||
|
|
||||||
const geminiColumns = useTerminalSize().columns - 20;
|
const geminiColumns = useTerminalSize().columns - 20;
|
||||||
|
|
||||||
|
const renderGeminiModelField = (field: GeminiField, customField: GeminiField, label: string) => {
|
||||||
|
const active = activeField === field || activeField === customField;
|
||||||
|
const val = geminiDisplayValues[field];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
<Box>
|
||||||
|
<Text
|
||||||
|
backgroundColor={activeField === field ? 'suggestion' : undefined}
|
||||||
|
color={activeField === field ? 'inverseText' : undefined}
|
||||||
|
>
|
||||||
|
{` ${label} `}
|
||||||
|
</Text>
|
||||||
|
<Text> </Text>
|
||||||
|
{activeField === field ? (
|
||||||
|
<Select
|
||||||
|
options={[
|
||||||
|
...availableModels.map(m => ({ label: m, value: m })),
|
||||||
|
{ label: 'Custom (Type your own)', value: '__custom__' },
|
||||||
|
]}
|
||||||
|
onChange={val => {
|
||||||
|
if (val === '__custom__') {
|
||||||
|
const nextState = buildGeminiState(field, '', customField);
|
||||||
|
setOAuthStatus(nextState);
|
||||||
|
setGeminiInputValue('');
|
||||||
|
setGeminiInputCursorOffset(0);
|
||||||
|
} else {
|
||||||
|
const nextState = buildGeminiState(field, val);
|
||||||
|
if (field === 'opus_model') {
|
||||||
|
setOAuthStatus(nextState);
|
||||||
|
doGeminiSave(nextState);
|
||||||
|
} else {
|
||||||
|
// Advance to next field
|
||||||
|
const idx = GEMINI_FIELDS.indexOf(field);
|
||||||
|
const next = GEMINI_FIELDS[idx + 1]!;
|
||||||
|
const advancedState = buildGeminiState(field, val, next);
|
||||||
|
setOAuthStatus(advancedState);
|
||||||
|
setGeminiInputValue(geminiDisplayValues[next] ?? '');
|
||||||
|
setGeminiInputCursorOffset((geminiDisplayValues[next] ?? '').length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : activeField === customField ? (
|
||||||
|
<TextInput
|
||||||
|
value={geminiInputValue}
|
||||||
|
onChange={setGeminiInputValue}
|
||||||
|
onSubmit={handleGeminiEnter}
|
||||||
|
cursorOffset={geminiInputCursorOffset}
|
||||||
|
onChangeCursorOffset={setGeminiInputCursorOffset}
|
||||||
|
columns={geminiColumns}
|
||||||
|
focus={true}
|
||||||
|
/>
|
||||||
|
) : val ? (
|
||||||
|
<Text color="success">{val}</Text>
|
||||||
|
) : null}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const renderGeminiRow = (field: GeminiField, label: string, opts?: { mask?: boolean }) => {
|
const renderGeminiRow = (field: GeminiField, label: string, opts?: { mask?: boolean }) => {
|
||||||
const active = activeField === field;
|
const active = activeField === field;
|
||||||
const val = geminiDisplayValues[field];
|
const val = geminiDisplayValues[field];
|
||||||
|
|
@ -1611,17 +1772,41 @@ function OAuthStatusMessage({
|
||||||
<Box flexDirection="column" gap={1}>
|
<Box flexDirection="column" gap={1}>
|
||||||
<Text bold>Gemini API Setup</Text>
|
<Text bold>Gemini API Setup</Text>
|
||||||
<Text dimColor>
|
<Text dimColor>
|
||||||
Configure a Gemini Generate Content compatible endpoint. Base URL is optional and defaults to Google's
|
Configure a Gemini Generate Content compatible endpoint. Models will be fetched automatically. Leave API Key
|
||||||
v1beta API. Leave API Key blank to log in via browser (Google Auth).
|
blank to log in via browser (Google Auth).
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Box flexDirection="column" gap={1}>
|
<Box flexDirection="column" gap={1}>
|
||||||
{renderGeminiRow('base_url', 'Base URL ')}
|
{(activeField === 'api_key' ||
|
||||||
{renderGeminiRow('api_key', 'API Key ', { mask: true })}
|
GEMINI_FIELDS.indexOf(activeField as any) >= GEMINI_FIELDS.indexOf('api_key')) &&
|
||||||
{renderGeminiRow('haiku_model', 'Haiku ')}
|
renderGeminiRow('api_key', 'API Key ', { mask: true })}
|
||||||
{renderGeminiRow('sonnet_model', 'Sonnet ')}
|
|
||||||
{renderGeminiRow('opus_model', 'Opus ')}
|
{isLoadingModels && (
|
||||||
|
<Box gap={1}>
|
||||||
|
<Spinner />
|
||||||
|
<Text>{statusMessage || 'Loading...'}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoadingModels &&
|
||||||
|
(activeField === 'haiku_model' ||
|
||||||
|
activeField === 'custom_haiku_model' ||
|
||||||
|
GEMINI_FIELDS.indexOf(activeField as any) > GEMINI_FIELDS.indexOf('haiku_model')) &&
|
||||||
|
renderGeminiModelField('haiku_model', 'custom_haiku_model', 'Haiku ')}
|
||||||
|
|
||||||
|
{!isLoadingModels &&
|
||||||
|
(activeField === 'sonnet_model' ||
|
||||||
|
activeField === 'custom_sonnet_model' ||
|
||||||
|
GEMINI_FIELDS.indexOf(activeField as any) > GEMINI_FIELDS.indexOf('sonnet_model')) &&
|
||||||
|
renderGeminiModelField('sonnet_model', 'custom_sonnet_model', 'Sonnet ')}
|
||||||
|
|
||||||
|
{!isLoadingModels &&
|
||||||
|
(activeField === 'opus_model' ||
|
||||||
|
activeField === 'custom_opus_model' ||
|
||||||
|
GEMINI_FIELDS.indexOf(activeField as any) > GEMINI_FIELDS.indexOf('opus_model')) &&
|
||||||
|
renderGeminiModelField('opus_model', 'custom_opus_model', 'Opus ')}
|
||||||
</Box>
|
</Box>
|
||||||
<Text dimColor>↑↓/Tab to switch · Enter on last field to save · Esc to go back</Text>
|
<Text dimColor>↑↓ to select options · Enter to save/fetch models · Esc to go back</Text>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
73
src/components/__tests__/ConsoleOAuthFlow.test.tsx
Normal file
73
src/components/__tests__/ConsoleOAuthFlow.test.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { describe, expect, test, mock } from 'bun:test';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
mock.module('react', () => ({
|
||||||
|
...React,
|
||||||
|
useState: (initial: any) => [typeof initial === 'function' ? initial() : initial, () => {}],
|
||||||
|
useEffect: () => {},
|
||||||
|
useRef: (initial: any) => ({ current: initial }),
|
||||||
|
useCallback: (fn: any) => fn,
|
||||||
|
useMemo: (fn: any) => fn(),
|
||||||
|
useContext: () => ({}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ConsoleOAuthFlow } from '../ConsoleOAuthFlow.js';
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
mock.module('src/services/analytics/index.js', () => ({
|
||||||
|
logEvent: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module('../utils/localLlm.js', () => ({
|
||||||
|
checkOllamaStatus: async () => true,
|
||||||
|
listOllamaModels: async () => ['llama3.1', 'mistral'],
|
||||||
|
pullOllamaModel: async () => {},
|
||||||
|
pingUrl: async () => true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module('../utils/settings/settings.js', () => ({
|
||||||
|
getSettings_DEPRECATED: () => ({}),
|
||||||
|
updateSettingsForSource: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module('../utils/auth.js', () => ({
|
||||||
|
getOauthAccountInfo: async () => ({}),
|
||||||
|
validateForceLoginOrg: async () => true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module('../services/oauth/index.js', () => ({
|
||||||
|
OAuthService: {
|
||||||
|
start: async () => {},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module('@anthropic/ink', () => ({
|
||||||
|
useTerminalNotification: () => () => {},
|
||||||
|
setClipboard: () => {},
|
||||||
|
Box: ({ children }: any) => <div>{children}</div>,
|
||||||
|
Link: ({ children }: any) => <div>{children}</div>,
|
||||||
|
Text: ({ children }: any) => <div>{children}</div>,
|
||||||
|
KeyboardShortcutHint: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module('../hooks/useTerminalSize.js', () => ({
|
||||||
|
useTerminalSize: () => ({ columns: 80, rows: 24 }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
mock.module('../keybindings/useKeybinding.js', () => ({
|
||||||
|
useKeybinding: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('ConsoleOAuthFlow', () => {
|
||||||
|
test('renders initial login method selection', () => {
|
||||||
|
const onDone = () => {};
|
||||||
|
const element = ConsoleOAuthFlow({ onDone }) as React.ReactElement;
|
||||||
|
|
||||||
|
// The component returns a React element tree
|
||||||
|
// We expect it to contain the title and options
|
||||||
|
const str = JSON.stringify(element);
|
||||||
|
expect(str).toContain('Select login method');
|
||||||
|
expect(str).toContain('Anthropic Console');
|
||||||
|
expect(str).toContain('Local LLM');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -333,16 +333,26 @@ export function AttachmentMessage({ attachment, addMargin, verbose, isTranscript
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
case 'hook_non_blocking_error': {
|
case 'hook_non_blocking_error': {
|
||||||
// Stop hooks are rendered as a summary in SystemStopHookSummaryMessage
|
// Stop/Start hooks are rendered as a summary or suppressed to avoid clutter
|
||||||
if (attachment.hookEvent === 'Stop' || attachment.hookEvent === 'SubagentStop') {
|
if (
|
||||||
|
attachment.hookEvent === 'Stop' ||
|
||||||
|
attachment.hookEvent === 'SubagentStop' ||
|
||||||
|
attachment.hookEvent === 'SessionStart' ||
|
||||||
|
attachment.hookEvent === 'SubagentStart'
|
||||||
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
// Full hook output is logged to debug log via hookEvents.ts
|
// Full hook output is logged to debug log via hookEvents.ts
|
||||||
return <Line color="error">{attachment.hookName} hook error</Line>;
|
return <Line color="error">{attachment.hookName} hook error</Line>;
|
||||||
}
|
}
|
||||||
case 'hook_error_during_execution':
|
case 'hook_error_during_execution':
|
||||||
// Stop hooks are rendered as a summary in SystemStopHookSummaryMessage
|
// Stop/Start hooks are rendered as a summary or suppressed to avoid clutter
|
||||||
if (attachment.hookEvent === 'Stop' || attachment.hookEvent === 'SubagentStop') {
|
if (
|
||||||
|
attachment.hookEvent === 'Stop' ||
|
||||||
|
attachment.hookEvent === 'SubagentStop' ||
|
||||||
|
attachment.hookEvent === 'SessionStart' ||
|
||||||
|
attachment.hookEvent === 'SubagentStart'
|
||||||
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
// Full hook output is logged to debug log via hookEvents.ts
|
// Full hook output is logged to debug log via hookEvents.ts
|
||||||
|
|
|
||||||
|
|
@ -144,7 +144,10 @@ function getAntModelOverrideSection(): string | null {
|
||||||
function getLanguageSection(
|
function getLanguageSection(
|
||||||
languagePreference: string | undefined,
|
languagePreference: string | undefined,
|
||||||
): string | null {
|
): string | null {
|
||||||
if (!languagePreference) return null
|
if (!languagePreference) {
|
||||||
|
return `# Language
|
||||||
|
Always respond in the language of the user's input. Even though some system instructions or contextual documents are in Chinese, you MUST reply in the language the user speaks to you in (e.g., if they speak English, reply in English).`
|
||||||
|
}
|
||||||
|
|
||||||
return `# Language
|
return `# Language
|
||||||
Always respond in ${languagePreference}. Use ${languagePreference} for all explanations, comments, and communications with the user. Technical terms and code identifiers should remain in their original form.`
|
Always respond in ${languagePreference}. Use ${languagePreference} for all explanations, comments, and communications with the user. Technical terms and code identifiers should remain in their original form.`
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,7 @@ const DEFAULT_GEMINI_BASE_URL =
|
||||||
const STREAM_DECODE_OPTS: TextDecodeOptions = { stream: true }
|
const STREAM_DECODE_OPTS: TextDecodeOptions = { stream: true }
|
||||||
|
|
||||||
function getGeminiBaseUrl(): string {
|
function getGeminiBaseUrl(): string {
|
||||||
return (process.env.GEMINI_BASE_URL || DEFAULT_GEMINI_BASE_URL).replace(
|
return DEFAULT_GEMINI_BASE_URL.replace(/\/+$/, '')
|
||||||
/\/+$/,
|
|
||||||
'',
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getGeminiModelPath(model: string): string {
|
function getGeminiModelPath(model: string): string {
|
||||||
|
|
@ -24,6 +21,42 @@ function getGeminiModelPath(model: string): string {
|
||||||
return normalized.startsWith('models/') ? normalized : `models/${normalized}`
|
return normalized.startsWith('models/') ? normalized : `models/${normalized}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listGeminiModels(apiKey?: string): Promise<string[]> {
|
||||||
|
const url = `${getGeminiBaseUrl()}/models`
|
||||||
|
const headers: Record<string, string> = {}
|
||||||
|
|
||||||
|
if (apiKey) {
|
||||||
|
headers['x-goog-api-key'] = apiKey
|
||||||
|
} else {
|
||||||
|
const token = await getGoogleAccessToken()
|
||||||
|
if (token) {
|
||||||
|
headers['Authorization'] = `Bearer ${token}`
|
||||||
|
} else {
|
||||||
|
throw new Error('No API key or Google Auth token available')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers,
|
||||||
|
...getProxyFetchOptions({ forAnthropicAPI: false }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.text()
|
||||||
|
throw new Error(
|
||||||
|
`Failed to fetch Gemini models (${response.status} ${response.statusText}): ${body || 'empty response body'}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
if (!data || !Array.isArray(data.models)) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.models.map((m: any) => m.name.replace(/^models\//, ''))
|
||||||
|
}
|
||||||
|
|
||||||
export async function* streamGeminiGenerateContent(params: {
|
export async function* streamGeminiGenerateContent(params: {
|
||||||
model: string
|
model: string
|
||||||
body: GeminiGenerateContentRequest
|
body: GeminiGenerateContentRequest
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,31 @@ import { updateSettingsForSource } from 'src/utils/settings/settings.js'
|
||||||
import { getInitialSettings as getSettings } from 'src/utils/settings/settings.js'
|
import { getInitialSettings as getSettings } from 'src/utils/settings/settings.js'
|
||||||
import { logEvent } from 'src/services/analytics/index.js'
|
import { logEvent } from 'src/services/analytics/index.js'
|
||||||
import * as crypto from 'crypto' // For state generation if needed
|
import * as crypto from 'crypto' // For state generation if needed
|
||||||
|
import * as fs from 'fs'
|
||||||
|
import * as path from 'path'
|
||||||
|
|
||||||
const GOOGLE_CLIENT_ID = '32555940559.apps.googleusercontent.com'
|
let GOOGLE_CLIENT_ID = ''
|
||||||
const GOOGLE_CLIENT_SECRET = 'ZmssLNjJy2998hD4CTg2ejr2'
|
let GOOGLE_CLIENT_SECRET = ''
|
||||||
const SCOPES = ['https://www.googleapis.com/auth/cloud-platform']
|
|
||||||
|
try {
|
||||||
|
const oauthPath = path.join(process.cwd(), '.files', 'OAuth.json')
|
||||||
|
if (fs.existsSync(oauthPath)) {
|
||||||
|
const data = JSON.parse(fs.readFileSync(oauthPath, 'utf8'))
|
||||||
|
const config = data.web || data.installed
|
||||||
|
if (config && config.client_id && config.client_secret) {
|
||||||
|
GOOGLE_CLIENT_ID = config.client_id
|
||||||
|
GOOGLE_CLIENT_SECRET = config.client_secret
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore errors reading OAuth.json
|
||||||
|
}
|
||||||
|
const SCOPES = [
|
||||||
|
'https://www.googleapis.com/auth/generative-language.retriever',
|
||||||
|
'https://www.googleapis.com/auth/cloud-platform',
|
||||||
|
'https://www.googleapis.com/auth/userinfo.email',
|
||||||
|
'https://www.googleapis.com/auth/userinfo.profile',
|
||||||
|
]
|
||||||
|
|
||||||
export async function loginToGoogle(): Promise<void> {
|
export async function loginToGoogle(): Promise<void> {
|
||||||
const listener = new AuthCodeListener('/')
|
const listener = new AuthCodeListener('/')
|
||||||
|
|
@ -50,9 +71,24 @@ export async function loginToGoogle(): Promise<void> {
|
||||||
res.writeHead(200, { 'Content-Type': 'text/html' })
|
res.writeHead(200, { 'Content-Type': 'text/html' })
|
||||||
res.end(`
|
res.end(`
|
||||||
<html>
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Authentication successful</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: sans-serif; padding: 2rem; }
|
||||||
|
h1 { color: #1a73e8; }
|
||||||
|
ul { line-height: 1.6; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>Successfully logged in to Google!</h1>
|
<h1>Authentication successful</h1>
|
||||||
<p>You can close this tab and return to Claude Code.</p>
|
<p>The authentication was successful, and the following products are now authorized to access your account:</p>
|
||||||
|
<ul>
|
||||||
|
<li>Gemini Code Assist</li>
|
||||||
|
<li>Cloud Code with Gemini Code Assist</li>
|
||||||
|
<li>Gemini CLI</li>
|
||||||
|
<li>Antigravity (available only for free, Google One AI Pro, Google One AI Ultra, and Google Workspace AI Ultra for Business)</li>
|
||||||
|
</ul>
|
||||||
|
<p>You can close this window and return to your IDE or terminal.</p>
|
||||||
<script>window.close();</script>
|
<script>window.close();</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user