feat: /login local model
Browse files- src/commands/login/login.tsx +19 -1
- src/components/LocalLoginFlow.tsx +97 -0
- src/utils/auth.ts +24 -0
- src/utils/model/providers.ts +7 -1
src/commands/login/login.tsx
CHANGED
|
@@ -23,6 +23,7 @@ import { Dialog } from '../../components/design-system/Dialog.js';
|
|
| 23 |
// TODO
|
| 24 |
import { OpenAILoginFlow } from '../../components/OpenAILoginFlow.js'
|
| 25 |
import { OpenRouterLoginFlow } from '../../components/OpenRouterLoginFlow.js'
|
|
|
|
| 26 |
|
| 27 |
import { useMainLoopModel } from '../../hooks/useMainLoopModel.js';
|
| 28 |
|
|
@@ -46,7 +47,7 @@ import {
|
|
| 46 |
import { resetUserCache } from '../../utils/user.js';
|
| 47 |
|
| 48 |
// TODO
|
| 49 |
-
type AuthProviderChoice = 'anthropic' | 'openai' | 'openrouter'
|
| 50 |
|
| 51 |
/* 第一层: 入口函数 call()
|
| 52 |
* CLI 执行 /login 真正被调用的函数
|
|
@@ -171,6 +172,18 @@ export function Login(props: {
|
|
| 171 |
),
|
| 172 |
value: 'openrouter',
|
| 173 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
],
|
| 175 |
[],
|
| 176 |
)
|
|
@@ -209,6 +222,11 @@ export function Login(props: {
|
|
| 209 |
onDone={onFlowDone}
|
| 210 |
startingMessage="Better-Clawd can use OpenRouter with your OpenRouter API key."
|
| 211 |
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
) : (
|
| 213 |
<ConsoleOAuthFlow
|
| 214 |
onDone={onFlowDone}
|
|
|
|
| 23 |
// TODO
|
| 24 |
import { OpenAILoginFlow } from '../../components/OpenAILoginFlow.js'
|
| 25 |
import { OpenRouterLoginFlow } from '../../components/OpenRouterLoginFlow.js'
|
| 26 |
+
import { LocalLoginFlow } from '../../components/LocalLoginFlow.js'
|
| 27 |
|
| 28 |
import { useMainLoopModel } from '../../hooks/useMainLoopModel.js';
|
| 29 |
|
|
|
|
| 47 |
import { resetUserCache } from '../../utils/user.js';
|
| 48 |
|
| 49 |
// TODO
|
| 50 |
+
type AuthProviderChoice = 'anthropic' | 'openai' | 'openrouter' | 'local'
|
| 51 |
|
| 52 |
/* 第一层: 入口函数 call()
|
| 53 |
* CLI 执行 /login 真正被调用的函数
|
|
|
|
| 172 |
),
|
| 173 |
value: 'openrouter',
|
| 174 |
},
|
| 175 |
+
{
|
| 176 |
+
label: (
|
| 177 |
+
<Text>
|
| 178 |
+
Local{' '}
|
| 179 |
+
<Text dimColor={true}>
|
| 180 |
+
Local model server (Ollama, LM Studio, vLLM, etc.)
|
| 181 |
+
</Text>
|
| 182 |
+
{'\n'}
|
| 183 |
+
</Text>
|
| 184 |
+
),
|
| 185 |
+
value: 'local',
|
| 186 |
+
},
|
| 187 |
],
|
| 188 |
[],
|
| 189 |
)
|
|
|
|
| 222 |
onDone={onFlowDone}
|
| 223 |
startingMessage="Better-Clawd can use OpenRouter with your OpenRouter API key."
|
| 224 |
/>
|
| 225 |
+
) : selectedProvider === 'local' ? (
|
| 226 |
+
<LocalLoginFlow
|
| 227 |
+
onDone={onFlowDone}
|
| 228 |
+
startingMessage="Configure local model server (Ollama, LM Studio, vLLM, etc.)."
|
| 229 |
+
/>
|
| 230 |
) : (
|
| 231 |
<ConsoleOAuthFlow
|
| 232 |
onDone={onFlowDone}
|
src/components/LocalLoginFlow.tsx
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import * as React from 'react'
|
| 2 |
+
import { useState } from 'react'
|
| 3 |
+
import type { LocalJSXCommandOnDone } from '../../types/command.js'
|
| 4 |
+
import { saveLocalModelConfig } from '../utils/auth.js'
|
| 5 |
+
import { Box, Text } from '../ink.js'
|
| 6 |
+
import TextInput from './TextInput.js'
|
| 7 |
+
import { ConsoleOAuthFlow } from './ConsoleOAuthFlow.js'
|
| 8 |
+
import Dialog from './design-system/Dialog.js'
|
| 9 |
+
|
| 10 |
+
interface Props {
|
| 11 |
+
onDone: LocalJSXCommandOnDone
|
| 12 |
+
startingMessage?: string
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export function LocalLoginFlow({ onDone, startingMessage }: Props) {
|
| 16 |
+
const [url, setUrl] = useState('')
|
| 17 |
+
const [modelName, setModelName] = useState<string>('')
|
| 18 |
+
const [cursorOffset, setCursorOffset] = useState(0)
|
| 19 |
+
const [isAnalyzing, setIsAnalyzing] = useState(false)
|
| 20 |
+
const defaultUrl = 'http://127.0.0.1:8001'
|
| 21 |
+
|
| 22 |
+
// 分析 URL 提取模型名
|
| 23 |
+
const analyzeUrl = (inputUrl: string) => {
|
| 24 |
+
setIsAnalyzing(true)
|
| 25 |
+
try {
|
| 26 |
+
const urlObj = new URL(inputUrl)
|
| 27 |
+
// 从 URL 路径中提取模型名,例如 http://127.0.0.1:8001/v1/models
|
| 28 |
+
const pathParts = urlObj.pathname.split('/').filter(Boolean)
|
| 29 |
+
const extractedModel = pathParts[pathParts.length - 1] || 'default'
|
| 30 |
+
setModelName(extractedModel)
|
| 31 |
+
} catch {
|
| 32 |
+
setModelName('')
|
| 33 |
+
} finally {
|
| 34 |
+
setIsAnalyzing(false)
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
const handleUrlChange = (value: string) => {
|
| 39 |
+
setUrl(value)
|
| 40 |
+
analyzeUrl(value)
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
const handleSubmit = async () => {
|
| 44 |
+
const finalUrl = url.trim() || defaultUrl
|
| 45 |
+
const finalModel = modelName || 'default'
|
| 46 |
+
|
| 47 |
+
try {
|
| 48 |
+
// 保存本地模型配置
|
| 49 |
+
await saveLocalModelConfig(finalUrl, finalModel)
|
| 50 |
+
onDone(true)
|
| 51 |
+
} catch (error) {
|
| 52 |
+
console.error('Failed to save local model config:', error)
|
| 53 |
+
onDone(false)
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
const handleCancel = () => {
|
| 58 |
+
onDone(false)
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
return (
|
| 62 |
+
<Box flexDirection="column" gap={1}>
|
| 63 |
+
<Text bold={true}>
|
| 64 |
+
{startingMessage ?? 'Configure local model server.'}
|
| 65 |
+
</Text>
|
| 66 |
+
|
| 67 |
+
<Box flexDirection="column" gap={1}>
|
| 68 |
+
<Text>Enter local model server URL:</Text>
|
| 69 |
+
<TextInput
|
| 70 |
+
value={url}
|
| 71 |
+
onChange={handleUrlChange}
|
| 72 |
+
onSubmit={handleSubmit}
|
| 73 |
+
onExit={handleCancel}
|
| 74 |
+
cursorOffset={cursorOffset}
|
| 75 |
+
onChangeCursorOffset={setCursorOffset}
|
| 76 |
+
columns={72}
|
| 77 |
+
placeholder={defaultUrl}
|
| 78 |
+
focus={true}
|
| 79 |
+
/>
|
| 80 |
+
</Box>
|
| 81 |
+
|
| 82 |
+
{modelName && (
|
| 83 |
+
<Box flexDirection="column" gap={1}>
|
| 84 |
+
<Text dimColor={true}>
|
| 85 |
+
Detected model: <Text bold={true}>{modelName}</Text>
|
| 86 |
+
</Text>
|
| 87 |
+
</Box>
|
| 88 |
+
)}
|
| 89 |
+
|
| 90 |
+
<Box flexDirection="row" gap={2} marginTop={1}>
|
| 91 |
+
<Text color="subtle">Press Enter to confirm</Text>
|
| 92 |
+
<Text color="subtle">·</Text>
|
| 93 |
+
<Text color="subtle">Esc to cancel</Text>
|
| 94 |
+
</Box>
|
| 95 |
+
</Box>
|
| 96 |
+
)
|
| 97 |
+
}
|
src/utils/auth.ts
CHANGED
|
@@ -393,6 +393,30 @@ export async function saveOpenRouterApiKey(apiKey: string): Promise<void> {
|
|
| 393 |
clearOpenRouterModelsCache()
|
| 394 |
}
|
| 395 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 396 |
function getCodexHomeDir(): string {
|
| 397 |
return process.env.CODEX_HOME || join(homedir(), '.codex')
|
| 398 |
}
|
|
|
|
| 393 |
clearOpenRouterModelsCache()
|
| 394 |
}
|
| 395 |
|
| 396 |
+
export async function saveLocalModelConfig(baseUrl: string, modelName: string): Promise<void> {
|
| 397 |
+
if (!baseUrl.trim()) {
|
| 398 |
+
throw new Error('Base URL cannot be empty')
|
| 399 |
+
}
|
| 400 |
+
|
| 401 |
+
try {
|
| 402 |
+
// 验证 URL 格式
|
| 403 |
+
new URL(baseUrl)
|
| 404 |
+
} catch {
|
| 405 |
+
throw new Error('Invalid URL format')
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
saveGlobalConfig(current => ({
|
| 409 |
+
...current,
|
| 410 |
+
authProvider: 'local',
|
| 411 |
+
localBaseUrl: baseUrl,
|
| 412 |
+
localModelName: modelName,
|
| 413 |
+
}))
|
| 414 |
+
|
| 415 |
+
// Clear provider cache so it will be re-read on next access
|
| 416 |
+
const { clearStoredProviderCache } = await import('./model/providers.js')
|
| 417 |
+
clearStoredProviderCache()
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
function getCodexHomeDir(): string {
|
| 421 |
return process.env.CODEX_HOME || join(homedir(), '.codex')
|
| 422 |
}
|
src/utils/model/providers.ts
CHANGED
|
@@ -6,6 +6,7 @@ export type APIProvider =
|
|
| 6 |
| 'firstParty'
|
| 7 |
| 'openrouter'
|
| 8 |
| 'openai'
|
|
|
|
| 9 |
| 'bedrock'
|
| 10 |
| 'vertex'
|
| 11 |
| 'foundry'
|
|
@@ -24,10 +25,12 @@ function getStoredProviderPreference(): APIProvider | null {
|
|
| 24 |
const { readFileSync } = require('fs') as typeof import('fs')
|
| 25 |
const raw = readFileSync(getGlobalClaudeFile(), 'utf8')
|
| 26 |
const config = JSON.parse(raw) as {
|
| 27 |
-
authProvider?: 'anthropic' | 'openrouter' | 'openai'
|
| 28 |
openRouterApiKey?: string
|
| 29 |
openAiApiKey?: string
|
| 30 |
openAiAccessToken?: string
|
|
|
|
|
|
|
| 31 |
}
|
| 32 |
|
| 33 |
let result: APIProvider | null
|
|
@@ -41,6 +44,9 @@ function getStoredProviderPreference(): APIProvider | null {
|
|
| 41 |
? 'openai'
|
| 42 |
: null
|
| 43 |
break
|
|
|
|
|
|
|
|
|
|
| 44 |
case 'anthropic':
|
| 45 |
result = 'firstParty'
|
| 46 |
break
|
|
|
|
| 6 |
| 'firstParty'
|
| 7 |
| 'openrouter'
|
| 8 |
| 'openai'
|
| 9 |
+
| 'local'
|
| 10 |
| 'bedrock'
|
| 11 |
| 'vertex'
|
| 12 |
| 'foundry'
|
|
|
|
| 25 |
const { readFileSync } = require('fs') as typeof import('fs')
|
| 26 |
const raw = readFileSync(getGlobalClaudeFile(), 'utf8')
|
| 27 |
const config = JSON.parse(raw) as {
|
| 28 |
+
authProvider?: 'anthropic' | 'openrouter' | 'openai' | 'local'
|
| 29 |
openRouterApiKey?: string
|
| 30 |
openAiApiKey?: string
|
| 31 |
openAiAccessToken?: string
|
| 32 |
+
localBaseUrl?: string
|
| 33 |
+
localModelName?: string
|
| 34 |
}
|
| 35 |
|
| 36 |
let result: APIProvider | null
|
|
|
|
| 44 |
? 'openai'
|
| 45 |
: null
|
| 46 |
break
|
| 47 |
+
case 'local':
|
| 48 |
+
result = config.localBaseUrl ? 'local' : null
|
| 49 |
+
break
|
| 50 |
case 'anthropic':
|
| 51 |
result = 'firstParty'
|
| 52 |
break
|