diff --git a/.gitignore b/.gitignore index 742acd7ff..0855d0e9c 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,5 @@ data !.codex/prompts/** teach-me credentials.json +OAuth.json +.claude/agents/hello-agent.md diff --git a/README.md b/README.md index 63a1618b9..7655b775a 100644 --- a/README.md +++ b/README.md @@ -150,24 +150,17 @@ bun run build 1. **Anthropic Compatible**: 对接第三方 API 兼容服务(OpenRouter、AWS Bedrock 代理等)。 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**: **(推荐)** 使用本地运行的模型。 - 支持 **Ollama**, **LM Studio**, **Jan.ai**, **LocalAI**。 - - **Ollama 深度集成**: 可直接在 CLI 中查看已安装模型,或输入模型名(如 `llama3.1`)一键拉取(Pull)。 + - **Ollama 深度集成**: 可直接在 CLI 中查看已安装模型,或输入模型名(如 `llama3.1`)一键拉取(Pull)。支持模型列表交互切换和硬件状态自动检测。 - 自动检测本地运行状态和默认端口。 #### /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 即可。 ### 🩺 系统诊断 /doctor diff --git a/README_EN.md b/README_EN.md index 908f7260f..d2b24a910 100644 --- a/README_EN.md +++ b/README_EN.md @@ -143,20 +143,18 @@ If you encounter a bug, please open an issue; we prioritize solving them. ### 👤 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). -Options for OpenAI and Gemini are also available for their respective protocols. +After running for the first time, type `/login` in the REPL to enter the login configuration interface. -Fields to fill in: - -| 📌 Field | 📝 Description | 💡 Example | -| --- | --- | --- | -| Base URL | API Service Address | `https://api.example.com/v1` | -| API Key | Authentication Key | `sk-xxx` | -| Haiku Model | Fast Model ID | `claude-haiku-4-5-20251001` | -| Sonnet Model | Balanced Model ID | `claude-sonnet-4-6` | -| Opus Model | High Performance Model ID | `claude-opus-4-6` | - -- ⌨️ **Tab / Shift+Tab** to switch fields, **Enter** to confirm and jump to the next, press Enter on the last field to save. +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. + - **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). + 2. Download the credentials JSON format and save it as `/.files/OAuth.json` in the project root. + 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. +3. **Local LLM**: **(Recommended)** Use models running locally. + - Supports **Ollama**, **LM Studio**, **Jan.ai**, **LocalAI**. + - **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. > ℹ️ Supports all Anthropic API compatible services (e.g., OpenRouter, AWS Bedrock proxies, etc.), as long as the interface is compatible with the Messages API. diff --git a/src/components/ConsoleOAuthFlow.tsx b/src/components/ConsoleOAuthFlow.tsx index 689706676..43c0f3e0a 100644 --- a/src/components/ConsoleOAuthFlow.tsx +++ b/src/components/ConsoleOAuthFlow.tsx @@ -49,12 +49,21 @@ type OAuthStatus = } // OpenAI Chat Completions API platform | { state: 'gemini_api'; - baseUrl: string; apiKey: string; haikuModel: string; sonnetModel: 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 | { state: 'local_llm_setup'; @@ -152,7 +161,8 @@ export function ConsoleOAuthFlow({ oauthStatus.state === 'local_llm_setup' && oauthStatus.runnerType === 'ollama' && oauthStatus.availableModels.length === 0 && - !oauthStatus.isLoadingModels + !oauthStatus.isLoadingModels && + !oauthStatus.statusMessage ) { setOAuthStatus(prev => (prev.state === 'local_llm_setup' ? { ...prev, isLoadingModels: true } : prev)); listOllamaModels(oauthStatus.baseUrl) @@ -499,7 +509,7 @@ type OAuthStatusMessageProps = { setCursorOffset: (offset: number) => void; textInputColumns: number; handleSubmitCode: (value: string, url: string) => void; - setOAuthStatus: (status: OAuthStatus) => void; + setOAuthStatus: React.Dispatch>; setLoginWithClaudeAi: (value: boolean) => void; }; @@ -648,12 +658,13 @@ function OAuthStatusMessage({ logEvent('tengu_gemini_api_selected', {}); setOAuthStatus({ state: 'gemini_api', - baseUrl: process.env.GEMINI_BASE_URL ?? '', apiKey: process.env.GEMINI_API_KEY ?? '', haikuModel: process.env.GEMINI_DEFAULT_HAIKU_MODEL ?? '', sonnetModel: process.env.GEMINI_DEFAULT_SONNET_MODEL ?? '', opusModel: process.env.GEMINI_DEFAULT_OPUS_MODEL ?? '', - activeField: 'base_url', + activeField: 'api_key', + availableModels: [], + isLoadingModels: false, }); } else if (value === 'platform') { logEvent('tengu_oauth_platform_selected', {}); @@ -1406,29 +1417,49 @@ function OAuthStatusMessage({ } case 'gemini_api': { - type GeminiField = 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model'; - const GEMINI_FIELDS: GeminiField[] = ['base_url', 'api_key', 'haiku_model', 'sonnet_model', 'opus_model']; + type GeminiField = + | '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 { state: 'gemini_api'; activeField: GeminiField; - baseUrl: string; apiKey: string; haikuModel: string; sonnetModel: string; opusModel: string; + availableModels: string[]; + isLoadingModels: boolean; + statusMessage?: string; }; - const { activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel } = gp; - const geminiDisplayValues: Record = { - base_url: baseUrl, + const { + activeField, + apiKey, + haikuModel, + sonnetModel, + opusModel, + availableModels, + isLoadingModels, + statusMessage, + } = gp; + const geminiDisplayValues: Record = { api_key: apiKey, haiku_model: haikuModel, sonnet_model: sonnetModel, 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( - () => geminiDisplayValues[activeField].length, + () => (geminiDisplayValues[activeField] ?? '').length, ); const buildGeminiState = useCallback( @@ -1436,137 +1467,206 @@ function OAuthStatusMessage({ const s = { state: 'gemini_api' as const, activeField: newActive ?? activeField, - baseUrl, apiKey, haikuModel, sonnetModel, opusModel, + availableModels, + isLoadingModels, + statusMessage, }; switch (field) { - case 'base_url': - return { ...s, baseUrl: value }; case 'api_key': return { ...s, apiKey: value }; case 'haiku_model': + case 'custom_haiku_model': return { ...s, haikuModel: value }; case 'sonnet_model': + case 'custom_sonnet_model': return { ...s, sonnetModel: value }; case 'opus_model': + case 'custom_opus_model': 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 finalVals = { ...geminiDisplayValues, [activeField]: geminiInputValue }; - if (!finalVals.haiku_model || !finalVals.sonnet_model || !finalVals.opus_model) { - setOAuthStatus({ - state: 'error', - message: 'Gemini setup requires Haiku, Sonnet, and Opus model names.', - toRetry: { - state: 'gemini_api', - baseUrl: finalVals.base_url, - apiKey: finalVals.api_key, - haikuModel: finalVals.haiku_model, - sonnetModel: finalVals.sonnet_model, - opusModel: finalVals.opus_model, - activeField, - }, - }); - return; - } - - if (!finalVals.api_key) { - setOAuthStatus({ state: 'waiting_for_login' }); + const fetchGeminiModels = useCallback( + async (currentApiKey: string) => { + setOAuthStatus((prev: OAuthStatus) => + prev.state === 'gemini_api' + ? { + ...prev, + isLoadingModels: true, + statusMessage: currentApiKey ? 'Fetching models...' : 'Authenticating via browser...', + } + : prev, + ); try { - const { loginToGoogle } = await import('src/services/api/gemini/google-oauth.js'); - await loginToGoogle(); + if (!currentApiKey) { + 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) { + setOAuthStatus((prev: OAuthStatus) => + prev.state === 'gemini_api' + ? { + ...prev, + isLoadingModels: false, + statusMessage: undefined, + } + : prev, + ); setOAuthStatus({ state: 'error', - message: `Google Login failed: ${e instanceof Error ? e.message : e}`, + message: `Failed to fetch models: ${e instanceof Error ? e.message : e}`, toRetry: { state: 'gemini_api', - baseUrl: finalVals.base_url, - apiKey: finalVals.api_key, - haikuModel: finalVals.haiku_model, - sonnetModel: finalVals.sonnet_model, - opusModel: finalVals.opus_model, + apiKey: currentApiKey, + haikuModel, + sonnetModel, + opusModel, 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; } - } - const env: Record = {}; - if (finalVals.base_url) env.GEMINI_BASE_URL = finalVals.base_url; - if (finalVals.api_key) env.GEMINI_API_KEY = finalVals.api_key; - if (finalVals.haiku_model) env.GEMINI_DEFAULT_HAIKU_MODEL = finalVals.haiku_model; - if (finalVals.sonnet_model) env.GEMINI_DEFAULT_SONNET_MODEL = finalVals.sonnet_model; - if (finalVals.opus_model) env.GEMINI_DEFAULT_OPUS_MODEL = finalVals.opus_model; - const { error } = updateSettingsForSource('userSettings', { - modelType: 'gemini' as any, - env, - } as any); - if (error) { - setOAuthStatus({ - state: 'error', - message: `Failed to save: ${error.message}`, - toRetry: { - state: 'gemini_api', - baseUrl: '', - apiKey: '', - haikuModel: '', - sonnetModel: '', - opusModel: '', - activeField: 'base_url', - }, - }); - } else { - for (const [k, v] of Object.entries(env)) process.env[k] = v; - setOAuthStatus({ state: 'success' }); - void onDone(); - } - }, [activeField, geminiInputValue, geminiDisplayValues, onDone, setOAuthStatus]); + const env: Record = {}; + if (finalApiKey) env.GEMINI_API_KEY = finalApiKey; + if (finalHaiku) env.GEMINI_DEFAULT_HAIKU_MODEL = finalHaiku; + if (finalSonnet) env.GEMINI_DEFAULT_SONNET_MODEL = finalSonnet; + if (finalOpus) env.GEMINI_DEFAULT_OPUS_MODEL = finalOpus; + const { error } = updateSettingsForSource('userSettings', { + modelType: 'gemini' as any, + env, + } as any); + if (error) { + setOAuthStatus({ + state: 'error', + message: `Failed to save: ${error.message}`, + toRetry: { + ...stateToSave, + activeField: 'api_key', + }, + }); + } else { + for (const [k, v] of Object.entries(env)) process.env[k] = v; + setOAuthStatus({ state: 'success' }); + void onDone(); + } + }, + [activeField, onDone, setOAuthStatus], + ); 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) { - setOAuthStatus(buildGeminiState(activeField, geminiInputValue)); - doGeminiSave(); + const nextState = buildGeminiState(activeField, geminiInputValue); + setOAuthStatus(nextState); + doGeminiSave(nextState); } else { const next = GEMINI_FIELDS[idx + 1]!; - setOAuthStatus(buildGeminiState(activeField, geminiInputValue, next)); + const nextState = buildGeminiState(activeField, geminiInputValue, next); + setOAuthStatus(nextState); setGeminiInputValue(geminiDisplayValues[next] ?? ''); 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( 'tabs:next', () => { - const idx = GEMINI_FIELDS.indexOf(activeField); + const idx = GEMINI_FIELDS.indexOf(activeField as any); if (idx < GEMINI_FIELDS.length - 1) { - setOAuthStatus(buildGeminiState(activeField, geminiInputValue, GEMINI_FIELDS[idx + 1])); - setGeminiInputValue(geminiDisplayValues[GEMINI_FIELDS[idx + 1]!] ?? ''); - setGeminiInputCursorOffset((geminiDisplayValues[GEMINI_FIELDS[idx + 1]!] ?? '').length); + const next = GEMINI_FIELDS[idx + 1]!; + const nextState = buildGeminiState(activeField, geminiInputValue, next); + setOAuthStatus(nextState); + setGeminiInputValue(geminiDisplayValues[next] ?? ''); + setGeminiInputCursorOffset((geminiDisplayValues[next] ?? '').length); } }, - { context: 'FormField' }, + { context: 'FormField', isActive: isTextInputActive }, ); useKeybinding( 'tabs:previous', () => { - const idx = GEMINI_FIELDS.indexOf(activeField); + const idx = GEMINI_FIELDS.indexOf(activeField as any); if (idx > 0) { - setOAuthStatus(buildGeminiState(activeField, geminiInputValue, GEMINI_FIELDS[idx - 1])); - setGeminiInputValue(geminiDisplayValues[GEMINI_FIELDS[idx - 1]!] ?? ''); - setGeminiInputCursorOffset((geminiDisplayValues[GEMINI_FIELDS[idx - 1]!] ?? '').length); + const prev = GEMINI_FIELDS[idx - 1]!; + const nextState = buildGeminiState(activeField, geminiInputValue, prev); + setOAuthStatus(nextState); + setGeminiInputValue(geminiDisplayValues[prev] ?? ''); + setGeminiInputCursorOffset((geminiDisplayValues[prev] ?? '').length); } }, - { context: 'FormField' }, + { context: 'FormField', isActive: isTextInputActive }, ); useKeybinding( 'confirm:no', @@ -1578,6 +1678,67 @@ function OAuthStatusMessage({ const geminiColumns = useTerminalSize().columns - 20; + const renderGeminiModelField = (field: GeminiField, customField: GeminiField, label: string) => { + const active = activeField === field || activeField === customField; + const val = geminiDisplayValues[field]; + + return ( + + + + {` ${label} `} + + + {activeField === field ? ( +