chenbhao commited on
Commit
c94dcb2
·
1 Parent(s): 458c76e

feat: /login local model

Browse files
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