ethagent 0.2.1 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +114 -32
- package/bin/ethagent.js +11 -2
- package/package.json +25 -7
- package/src/app/FirstRun.tsx +412 -0
- package/src/app/hooks/useCancelRequest.ts +22 -0
- package/src/app/hooks/useDoublePress.ts +46 -0
- package/src/app/hooks/useExitOnCtrlC.ts +36 -0
- package/src/app/input/AppInputProvider.tsx +116 -0
- package/src/app/input/appInputParser.ts +279 -0
- package/src/app/keybindings/KeybindingProvider.tsx +134 -0
- package/src/app/keybindings/resolver.ts +42 -0
- package/src/app/keybindings/types.ts +26 -0
- package/src/chat/ChatBottomPane.tsx +280 -0
- package/src/chat/ChatInput.tsx +722 -0
- package/src/chat/ChatScreen.tsx +1575 -0
- package/src/chat/ContextLimitView.tsx +95 -0
- package/src/chat/ContinuityEditReviewView.tsx +48 -0
- package/src/chat/ConversationStack.tsx +47 -0
- package/src/chat/CopyPicker.tsx +52 -0
- package/src/chat/MessageList.tsx +609 -0
- package/src/chat/PermissionPrompt.tsx +153 -0
- package/src/chat/PermissionsView.tsx +159 -0
- package/src/chat/PlanApprovalView.tsx +91 -0
- package/src/chat/ResumeView.tsx +267 -0
- package/src/chat/RewindView.tsx +386 -0
- package/src/chat/SessionStatus.tsx +51 -0
- package/src/chat/TranscriptView.tsx +202 -0
- package/src/chat/chatInputState.ts +247 -0
- package/src/chat/chatPaste.ts +49 -0
- package/src/chat/chatScreenUtils.ts +187 -0
- package/src/chat/chatSessionState.ts +142 -0
- package/src/chat/chatTurnOrchestrator.ts +701 -0
- package/src/chat/commands.ts +673 -0
- package/src/chat/textCursor.ts +202 -0
- package/src/chat/toolResultDisplay.ts +8 -0
- package/src/chat/transcriptViewport.ts +247 -0
- package/src/cli/ResetConfirmView.tsx +61 -0
- package/src/cli/main.tsx +177 -0
- package/src/cli/preview.tsx +19 -0
- package/src/cli/reset.ts +106 -0
- package/src/identity/continuity/editor.ts +149 -0
- package/src/identity/continuity/envelope.ts +345 -0
- package/src/identity/continuity/history.ts +153 -0
- package/src/identity/continuity/privateEdit.ts +334 -0
- package/src/identity/continuity/publicSkills.ts +173 -0
- package/src/identity/continuity/snapshots.ts +183 -0
- package/src/identity/continuity/storage.ts +507 -0
- package/src/identity/crypto/backupEnvelope.ts +486 -0
- package/src/identity/crypto/eth.ts +137 -0
- package/src/identity/hub/IdentityHub.tsx +845 -0
- package/src/identity/hub/identityHubEffects.ts +1100 -0
- package/src/identity/hub/identityHubModel.ts +291 -0
- package/src/identity/hub/identityHubReducer.ts +209 -0
- package/src/identity/hub/screens/BusyScreen.tsx +26 -0
- package/src/identity/hub/screens/ContinuityDashboardScreen.tsx +139 -0
- package/src/identity/hub/screens/CreateFlow.tsx +206 -0
- package/src/identity/hub/screens/DetailsScreen.tsx +64 -0
- package/src/identity/hub/screens/EditProfileFlow.tsx +145 -0
- package/src/identity/hub/screens/ErrorScreen.tsx +35 -0
- package/src/identity/hub/screens/IdentitySummary.tsx +70 -0
- package/src/identity/hub/screens/MenuScreen.tsx +117 -0
- package/src/identity/hub/screens/NetworkScreen.tsx +41 -0
- package/src/identity/hub/screens/RebackupStorageScreen.tsx +50 -0
- package/src/identity/hub/screens/RecoveryConfirmScreen.tsx +85 -0
- package/src/identity/hub/screens/RestoreFlow.tsx +206 -0
- package/src/identity/hub/screens/StorageCredentialScreen.tsx +128 -0
- package/src/identity/hub/screens/WalletApprovalScreen.tsx +43 -0
- package/src/identity/profile/imagePicker.ts +180 -0
- package/src/identity/registry/erc8004.ts +1106 -0
- package/src/identity/registry/registryConfig.ts +69 -0
- package/src/identity/storage/ipfs.ts +212 -0
- package/src/identity/storage/pinataJwt.ts +53 -0
- package/src/identity/wallet/browserWallet.ts +393 -0
- package/src/identity/wallet/wallet-page/wallet.html +1082 -0
- package/src/mcp/approvals.ts +113 -0
- package/src/mcp/config.ts +235 -0
- package/src/mcp/manager.ts +541 -0
- package/src/mcp/names.ts +19 -0
- package/src/mcp/output.ts +96 -0
- package/src/models/ModelPicker.tsx +1446 -0
- package/src/models/catalog.ts +296 -0
- package/src/models/huggingface.ts +651 -0
- package/src/models/llamacpp.ts +810 -0
- package/src/models/llamacppPreflight.ts +150 -0
- package/src/models/modelDisplay.ts +105 -0
- package/src/models/modelPickerOptions.ts +421 -0
- package/src/models/modelRecommendation.ts +140 -0
- package/src/models/runtimeDetection.ts +81 -0
- package/src/models/uncensoredCatalog.ts +86 -0
- package/src/providers/anthropic.ts +259 -0
- package/src/providers/contracts.ts +62 -0
- package/src/providers/errors.ts +62 -0
- package/src/providers/gemini.ts +152 -0
- package/src/providers/openai-chat.ts +472 -0
- package/src/providers/registry.ts +42 -0
- package/src/providers/retry.ts +58 -0
- package/src/providers/sse.ts +93 -0
- package/src/runtime/compaction.ts +389 -0
- package/src/runtime/cwd.ts +43 -0
- package/src/runtime/sessionMode.ts +55 -0
- package/src/runtime/systemPrompt.ts +209 -0
- package/src/runtime/toolClaimGuards.ts +143 -0
- package/src/runtime/toolExecution.ts +304 -0
- package/src/runtime/toolIntent.ts +163 -0
- package/src/runtime/turn.ts +858 -0
- package/src/storage/atomicWrite.ts +68 -0
- package/src/storage/config.ts +189 -0
- package/src/storage/factoryReset.ts +130 -0
- package/src/storage/history.ts +58 -0
- package/src/storage/identity.ts +99 -0
- package/src/storage/permissions.ts +76 -0
- package/src/storage/rewind.ts +246 -0
- package/src/storage/secrets.ts +181 -0
- package/src/storage/sessionExport.ts +49 -0
- package/src/storage/sessions.ts +482 -0
- package/src/tools/bashSafety.ts +174 -0
- package/src/tools/bashTool.ts +140 -0
- package/src/tools/changeDirectoryTool.ts +213 -0
- package/src/tools/contracts.ts +179 -0
- package/src/tools/deleteFileTool.ts +111 -0
- package/src/tools/editTool.ts +160 -0
- package/src/tools/editUtils.ts +170 -0
- package/src/tools/listDirectoryTool.ts +55 -0
- package/src/tools/mcpResourceTools.ts +95 -0
- package/src/tools/permissionRules.ts +85 -0
- package/src/tools/privateContinuityEditTool.ts +178 -0
- package/src/tools/privateContinuityReadTool.ts +107 -0
- package/src/tools/readTool.ts +85 -0
- package/src/tools/registry.ts +67 -0
- package/src/tools/writeFileTool.ts +142 -0
- package/src/ui/BrandSplash.tsx +193 -0
- package/src/ui/ProgressBar.tsx +34 -0
- package/src/ui/Select.tsx +143 -0
- package/src/ui/Spinner.tsx +269 -0
- package/src/ui/Surface.tsx +47 -0
- package/src/ui/TextInput.tsx +97 -0
- package/src/ui/theme.ts +59 -0
- package/src/utils/clipboard.ts +216 -0
- package/src/utils/markdownSegments.ts +51 -0
- package/src/utils/messages.ts +35 -0
- package/src/utils/withRetry.ts +280 -0
- package/src/cli.tsx +0 -147
|
@@ -0,0 +1,1446 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { Box, Text } from 'ink'
|
|
3
|
+
import { Select, type SelectOption } from '../ui/Select.js'
|
|
4
|
+
import { Spinner } from '../ui/Spinner.js'
|
|
5
|
+
import { TextInput } from '../ui/TextInput.js'
|
|
6
|
+
import { Surface } from '../ui/Surface.js'
|
|
7
|
+
import { ProgressBar } from '../ui/ProgressBar.js'
|
|
8
|
+
import { theme } from '../ui/theme.js'
|
|
9
|
+
import {
|
|
10
|
+
buildLlamaCppRunner,
|
|
11
|
+
DEFAULT_LLAMA_HOST,
|
|
12
|
+
detectLlamaCpp,
|
|
13
|
+
installLlamaCppRunner,
|
|
14
|
+
setLlamaCppServerPath,
|
|
15
|
+
startLlamaCppServer,
|
|
16
|
+
type LlamaCppInstallProgress,
|
|
17
|
+
type LlamaCppInstallResult,
|
|
18
|
+
type LlamaCppStartResult,
|
|
19
|
+
} from './llamacpp.js'
|
|
20
|
+
import { detectSpec, type SpecSnapshot } from './runtimeDetection.js'
|
|
21
|
+
import {
|
|
22
|
+
estimateGgufMachineFit,
|
|
23
|
+
orderGgufFilesForSpec,
|
|
24
|
+
recommendGgufFile,
|
|
25
|
+
type GgufMachineFit,
|
|
26
|
+
} from './modelRecommendation.js'
|
|
27
|
+
import { hasKey, rmKey, setKey } from '../storage/secrets.js'
|
|
28
|
+
import { defaultModelFor, type EthagentConfig, type ProviderId } from '../storage/config.js'
|
|
29
|
+
import { clearModelCatalogCache, discoverProviderModels, type ModelCatalogResult } from './catalog.js'
|
|
30
|
+
import { contextWindowInfo } from '../runtime/compaction.js'
|
|
31
|
+
import {
|
|
32
|
+
createHfDownloadPlan,
|
|
33
|
+
downloadHfModel,
|
|
34
|
+
fetchHuggingFaceRepoInfo,
|
|
35
|
+
findLocalHfModel,
|
|
36
|
+
ggufFiles,
|
|
37
|
+
loadLocalHfModels,
|
|
38
|
+
localModelId,
|
|
39
|
+
modelFromPlan,
|
|
40
|
+
parseHuggingFaceRef,
|
|
41
|
+
uninstallLocalHfModel,
|
|
42
|
+
type HfCredibility,
|
|
43
|
+
type HfDownloadPlan,
|
|
44
|
+
type HfDownloadProgress,
|
|
45
|
+
type HfRisk,
|
|
46
|
+
type HuggingFaceRepoInfo,
|
|
47
|
+
type HuggingFaceSibling,
|
|
48
|
+
type LocalHfModel,
|
|
49
|
+
} from './huggingface.js'
|
|
50
|
+
import {
|
|
51
|
+
buildLocalModelCatalogOptions,
|
|
52
|
+
buildModelPickerOptions,
|
|
53
|
+
catalogOptionValue,
|
|
54
|
+
LOCAL_MODEL_LINK_EXAMPLE,
|
|
55
|
+
LOCAL_MODEL_LINK_HINT,
|
|
56
|
+
MODEL_PICKER_CLOUD_PROVIDERS,
|
|
57
|
+
orderModelsForContextFit,
|
|
58
|
+
type CloudProviderId,
|
|
59
|
+
type ModelPickerContextFit,
|
|
60
|
+
type ModelPickerOptionsData,
|
|
61
|
+
} from './modelPickerOptions.js'
|
|
62
|
+
import { formatLocalHfModelDisplayName, formatModelDisplayName } from './modelDisplay.js'
|
|
63
|
+
import { fetchUncensoredGgufCatalog, type UncensoredCatalogEntry } from './uncensoredCatalog.js'
|
|
64
|
+
|
|
65
|
+
export type ModelPickerSelection =
|
|
66
|
+
| { kind: 'llamacpp'; model: string }
|
|
67
|
+
| { kind: 'cloud'; provider: CloudProviderId; model: string; keyJustSet: boolean }
|
|
68
|
+
|
|
69
|
+
type ModelPickerProps = {
|
|
70
|
+
currentConfig: EthagentConfig
|
|
71
|
+
currentProvider: ProviderId
|
|
72
|
+
currentModel: string
|
|
73
|
+
contextFit?: ModelPickerContextFit | null
|
|
74
|
+
featuredHfRepo?: string
|
|
75
|
+
onPick: (selection: ModelPickerSelection) => void
|
|
76
|
+
onCancel: () => void
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
type LoadedData = ModelPickerOptionsData
|
|
80
|
+
type LocalUninstallTarget = { kind: 'hf'; id: string; displayName: string; sizeBytes: number }
|
|
81
|
+
|
|
82
|
+
type State =
|
|
83
|
+
| { kind: 'loading' }
|
|
84
|
+
| { kind: 'list'; data: LoadedData }
|
|
85
|
+
| { kind: 'localCatalogLoading'; data: LoadedData }
|
|
86
|
+
| { kind: 'localCatalog'; data: LoadedData; catalog: UncensoredCatalogEntry[] }
|
|
87
|
+
| { kind: 'localCatalogError'; data: LoadedData; message: string }
|
|
88
|
+
| { kind: 'catalog'; provider: CloudProviderId; data: LoadedData }
|
|
89
|
+
| { kind: 'keyEntry'; provider: CloudProviderId; action: 'set' | 'edit'; data: LoadedData; submitting: boolean; error?: string }
|
|
90
|
+
| { kind: 'keyManage'; provider: CloudProviderId; data: LoadedData; submitting: boolean; error?: string }
|
|
91
|
+
| { kind: 'hfInput'; data: LoadedData; error?: string }
|
|
92
|
+
| { kind: 'hfLoading'; data: LoadedData; input: string }
|
|
93
|
+
| { kind: 'hfFilePick'; data: LoadedData; input: string; repo: HuggingFaceRepoInfo; files: HuggingFaceSibling[] }
|
|
94
|
+
| { kind: 'hfReview'; data: LoadedData; plan: HfDownloadPlan }
|
|
95
|
+
| { kind: 'hfDownloading'; data: LoadedData; plan: HfDownloadPlan; progress: HfDownloadProgress }
|
|
96
|
+
| { kind: 'hfDone'; data: LoadedData; model: LocalHfModel; alreadyInstalled?: boolean }
|
|
97
|
+
| { kind: 'hfError'; data: LoadedData; message: string; input?: string }
|
|
98
|
+
| { kind: 'localUninstallPick'; data: LoadedData }
|
|
99
|
+
| { kind: 'localUninstallConfirm'; data: LoadedData; target: LocalUninstallTarget }
|
|
100
|
+
| { kind: 'localUninstalling'; data: LoadedData; target: LocalUninstallTarget }
|
|
101
|
+
| { kind: 'localUninstallDone'; data: LoadedData; modelName: string }
|
|
102
|
+
| { kind: 'localUninstallError'; data: LoadedData; target: LocalUninstallTarget; message: string }
|
|
103
|
+
| { kind: 'localRunnerSetup'; data: LoadedData; model: LocalHfModel }
|
|
104
|
+
| { kind: 'localRunnerInstalling'; data: LoadedData; model: LocalHfModel; startedAt: number; progress: LlamaCppInstallProgress }
|
|
105
|
+
| { kind: 'localRunnerInstallFail'; data: LoadedData; model: LocalHfModel; result: Extract<LlamaCppInstallResult, { ok: false }> }
|
|
106
|
+
| { kind: 'localRunnerPathEntry'; data: LoadedData; model: LocalHfModel; submitting: boolean; error?: string }
|
|
107
|
+
| { kind: 'localRunnerStarting'; data: LoadedData; model: LocalHfModel; startedAt: number }
|
|
108
|
+
| { kind: 'localRunnerStartFail'; data: LoadedData; model: LocalHfModel; result: Extract<LlamaCppStartResult, { ok: false }> }
|
|
109
|
+
|
|
110
|
+
export const ModelPicker: React.FC<ModelPickerProps> = ({
|
|
111
|
+
currentConfig,
|
|
112
|
+
currentProvider,
|
|
113
|
+
currentModel,
|
|
114
|
+
contextFit,
|
|
115
|
+
featuredHfRepo,
|
|
116
|
+
onPick,
|
|
117
|
+
onCancel,
|
|
118
|
+
}) => {
|
|
119
|
+
const [state, setState] = useState<State>({ kind: 'loading' })
|
|
120
|
+
const hfAbortRef = useRef<AbortController | null>(null)
|
|
121
|
+
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
let cancelled = false
|
|
124
|
+
void (async () => {
|
|
125
|
+
const [llamaCpp, hfModels, machineSpec, keyEntries] = await Promise.all([
|
|
126
|
+
probeLlamaCpp(),
|
|
127
|
+
loadHfPickerModels(),
|
|
128
|
+
detectSpec(),
|
|
129
|
+
Promise.all(MODEL_PICKER_CLOUD_PROVIDERS.map(async p => [p, await hasKey(p)] as const)),
|
|
130
|
+
])
|
|
131
|
+
if (cancelled) return
|
|
132
|
+
const cloudKeys = Object.fromEntries(keyEntries) as Partial<Record<ProviderId, boolean>>
|
|
133
|
+
const catalogEntries = await Promise.all(
|
|
134
|
+
MODEL_PICKER_CLOUD_PROVIDERS
|
|
135
|
+
.filter(provider => cloudKeys[provider])
|
|
136
|
+
.map(async provider => [provider, await discoverProviderModels(configForProvider(currentConfig, provider))] as const),
|
|
137
|
+
)
|
|
138
|
+
if (cancelled) return
|
|
139
|
+
const cloudCatalogs = Object.fromEntries(catalogEntries) as Partial<Record<ProviderId, ModelCatalogResult>>
|
|
140
|
+
const data: LoadedData = {
|
|
141
|
+
llamaCpp,
|
|
142
|
+
hfModels,
|
|
143
|
+
machineSpec,
|
|
144
|
+
cloudKeys,
|
|
145
|
+
cloudCatalogs,
|
|
146
|
+
}
|
|
147
|
+
if (featuredHfRepo) {
|
|
148
|
+
const installedFeatured = await findInstalledHfModelForInput(featuredHfRepo)
|
|
149
|
+
if (cancelled) return
|
|
150
|
+
if (installedFeatured) {
|
|
151
|
+
setState({ kind: 'hfDone', data, model: installedFeatured, alreadyInstalled: true })
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
setState({ kind: 'list', data })
|
|
156
|
+
// If a featured repo was provided (first-run local flow), auto-inspect it
|
|
157
|
+
if (featuredHfRepo) {
|
|
158
|
+
await inspectHfInput({ kind: 'hfInput', data }, featuredHfRepo, setState)
|
|
159
|
+
}
|
|
160
|
+
})()
|
|
161
|
+
return () => { cancelled = true }
|
|
162
|
+
}, [currentConfig, featuredHfRepo])
|
|
163
|
+
|
|
164
|
+
useEffect(() => () => {
|
|
165
|
+
hfAbortRef.current?.abort()
|
|
166
|
+
}, [])
|
|
167
|
+
|
|
168
|
+
if (state.kind === 'loading') {
|
|
169
|
+
return (
|
|
170
|
+
<Surface title={contextFit ? 'Switch to Larger-Context Model' : 'Switch Provider / Model'} subtitle="Loading providers and models.">
|
|
171
|
+
<Spinner label="loading providers..." />
|
|
172
|
+
</Surface>
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (state.kind === 'hfInput') {
|
|
177
|
+
return (
|
|
178
|
+
<Surface
|
|
179
|
+
title="Add Local Model"
|
|
180
|
+
subtitle={LOCAL_MODEL_LINK_EXAMPLE}
|
|
181
|
+
footer="enter checks link · esc returns to picker"
|
|
182
|
+
>
|
|
183
|
+
<TextInput
|
|
184
|
+
label="model link"
|
|
185
|
+
placeholder={LOCAL_MODEL_LINK_HINT}
|
|
186
|
+
onSubmit={value => void inspectHfInput(state, value, setState)}
|
|
187
|
+
onCancel={() => setState({ kind: 'list', data: state.data })}
|
|
188
|
+
/>
|
|
189
|
+
{state.error ? <Text color="#e87070">{state.error}</Text> : null}
|
|
190
|
+
</Surface>
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (state.kind === 'hfLoading') {
|
|
195
|
+
return (
|
|
196
|
+
<Surface title="Checking Model Link" subtitle={state.input}>
|
|
197
|
+
<Spinner label="reading model page..." />
|
|
198
|
+
</Surface>
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (state.kind === 'hfFilePick') {
|
|
203
|
+
const options = buildHfFileOptions(state.repo, state.files, state.data.machineSpec, state.data.hfModels.map(model => model.id))
|
|
204
|
+
const recommendedIndex = Math.max(0, options.findIndex(option => option.subtext?.includes('recommended')))
|
|
205
|
+
return (
|
|
206
|
+
<Surface
|
|
207
|
+
title="Choose a Compatible File"
|
|
208
|
+
subtitle={`${state.repo.repoId} has ${state.files.length} compatible local model file${state.files.length === 1 ? '' : 's'}.`}
|
|
209
|
+
footer="enter selects · esc returns to link input"
|
|
210
|
+
>
|
|
211
|
+
<Select
|
|
212
|
+
options={options}
|
|
213
|
+
initialIndex={recommendedIndex}
|
|
214
|
+
maxVisible={10}
|
|
215
|
+
onSubmit={filename => void reviewHfFile(state, filename, setState)}
|
|
216
|
+
onCancel={() => setState({ kind: 'hfInput', data: state.data })}
|
|
217
|
+
/>
|
|
218
|
+
</Surface>
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (state.kind === 'hfReview') {
|
|
223
|
+
const { plan } = state
|
|
224
|
+
const canDownload = plan.review.risk !== 'high' && plan.review.runtime === 'llama.cpp runnable'
|
|
225
|
+
const fit = state.data.machineSpec ? estimateGgufMachineFit(plan.sizeBytes, state.data.machineSpec) : null
|
|
226
|
+
const recommended = state.data.machineSpec ? recommendGgufFile(plan.repo, ggufFiles(plan.repo), state.data.machineSpec) : null
|
|
227
|
+
return (
|
|
228
|
+
<Surface
|
|
229
|
+
title="Review Model Link"
|
|
230
|
+
subtitle="Only download models from creators you trust. Check the license and source before continuing."
|
|
231
|
+
footer="enter selects · esc returns to picker"
|
|
232
|
+
tone={plan.review.risk === 'high' ? 'error' : plan.review.risk === 'medium' ? 'muted' : 'primary'}
|
|
233
|
+
>
|
|
234
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
235
|
+
<Text color={theme.text}>{plan.displayName}</Text>
|
|
236
|
+
<Text color={theme.dim}>source: huggingface.co/{plan.repoId}</Text>
|
|
237
|
+
<Text color={theme.dim}>file: {friendlyFileName(plan.filename)}</Text>
|
|
238
|
+
<Text color={theme.dim}>license: {plan.repo.license ?? 'unknown'} · size: {formatBytes(plan.sizeBytes)}</Text>
|
|
239
|
+
{fit ? <Text color={fitColor(fit.fit)}>fit: {fitLabel(fit.fit, recommended?.file.filename === plan.filename)}</Text> : null}
|
|
240
|
+
<Text color={riskColor(plan.review.risk)}>safety: {safetyLabel(plan.review.risk)} · source: {credibilityLabel(plan.review.credibility)}</Text>
|
|
241
|
+
<Text color={theme.dim}>signals: {formatSignals(plan.repo.downloads, plan.repo.likes)}</Text>
|
|
242
|
+
<Text color={theme.dim}>notes: {friendlyReasons(plan.review.reasons).join('; ')}</Text>
|
|
243
|
+
</Box>
|
|
244
|
+
<Select<'download' | 'pick' | 'cancel'>
|
|
245
|
+
options={[
|
|
246
|
+
{ value: 'download', label: 'download this model', disabled: !canDownload },
|
|
247
|
+
{ value: 'pick', label: 'pick another file' },
|
|
248
|
+
{ value: 'cancel', label: 'cancel' },
|
|
249
|
+
]}
|
|
250
|
+
onSubmit={choice => {
|
|
251
|
+
if (choice === 'download') void startHfDownload(state, setState, hfAbortRef, onPick)
|
|
252
|
+
else if (choice === 'pick') void inspectHfInput({ kind: 'hfInput', data: state.data }, plan.repoId, setState)
|
|
253
|
+
else setState({ kind: 'list', data: state.data })
|
|
254
|
+
}}
|
|
255
|
+
onCancel={() => setState({ kind: 'list', data: state.data })}
|
|
256
|
+
/>
|
|
257
|
+
</Surface>
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (state.kind === 'hfDownloading') {
|
|
262
|
+
const total = state.progress.total ?? state.plan.sizeBytes
|
|
263
|
+
const completed = state.progress.completed ?? 0
|
|
264
|
+
const progress = total > 0 ? completed / total : 0
|
|
265
|
+
const suffix = total > 0 ? `${formatBytes(completed)} / ${formatBytes(total)}` : formatBytes(completed)
|
|
266
|
+
return (
|
|
267
|
+
<Surface title="Downloading Model" subtitle={state.plan.displayName}>
|
|
268
|
+
<Text color={theme.dim}>{state.progress.status}</Text>
|
|
269
|
+
<ProgressBar progress={progress} suffix={suffix} variant="rainbow" />
|
|
270
|
+
</Surface>
|
|
271
|
+
)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (state.kind === 'hfDone') {
|
|
275
|
+
return (
|
|
276
|
+
<Surface
|
|
277
|
+
title={state.alreadyInstalled ? 'Model Already Downloaded' : 'Model Ready'}
|
|
278
|
+
subtitle={state.model.displayName}
|
|
279
|
+
footer="enter selects · esc returns to picker"
|
|
280
|
+
>
|
|
281
|
+
<Select<'use' | 'back'>
|
|
282
|
+
options={[
|
|
283
|
+
{ value: 'use', label: 'use this model now' },
|
|
284
|
+
{ value: 'back', label: 'back to picker' },
|
|
285
|
+
]}
|
|
286
|
+
onSubmit={choice => {
|
|
287
|
+
if (choice === 'use') void startAndPickHfModel(state.model, state, setState, onPick)
|
|
288
|
+
else setState({ kind: 'list', data: state.data })
|
|
289
|
+
}}
|
|
290
|
+
onCancel={() => setState({ kind: 'list', data: state.data })}
|
|
291
|
+
/>
|
|
292
|
+
</Surface>
|
|
293
|
+
)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (state.kind === 'hfError') {
|
|
297
|
+
return (
|
|
298
|
+
<Surface title="Model Link Failed" subtitle={state.message} tone="error" footer="enter selects · esc returns to picker">
|
|
299
|
+
<Select<'retry' | 'back'>
|
|
300
|
+
options={[
|
|
301
|
+
{ value: 'retry', label: state.input ? 'retry link' : 'download another model' },
|
|
302
|
+
{ value: 'back', label: 'back to picker' },
|
|
303
|
+
]}
|
|
304
|
+
onSubmit={choice => {
|
|
305
|
+
if (choice === 'retry') setState({ kind: 'hfInput', data: state.data, error: state.input ? undefined : state.message })
|
|
306
|
+
else setState({ kind: 'list', data: state.data })
|
|
307
|
+
}}
|
|
308
|
+
onCancel={() => setState({ kind: 'list', data: state.data })}
|
|
309
|
+
/>
|
|
310
|
+
</Surface>
|
|
311
|
+
)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (state.kind === 'localUninstallPick') {
|
|
315
|
+
const targets = localUninstallTargets(state.data)
|
|
316
|
+
const options = targets.map(target => ({
|
|
317
|
+
value: `${target.kind}:${target.id}`,
|
|
318
|
+
label: target.displayName,
|
|
319
|
+
subtext: [
|
|
320
|
+
'downloaded GGUF file',
|
|
321
|
+
formatBytes(target.sizeBytes),
|
|
322
|
+
isCurrentLocalUninstallTarget(target, currentProvider, currentModel) ? 'currently selected' : '',
|
|
323
|
+
].filter(Boolean).join(' · '),
|
|
324
|
+
role: 'option' as const,
|
|
325
|
+
}))
|
|
326
|
+
return (
|
|
327
|
+
<Surface title="Uninstall Downloaded GGUF" subtitle="Choose a downloaded model file to remove." footer="enter selects · esc returns to picker">
|
|
328
|
+
{options.length === 0 ? (
|
|
329
|
+
<Text color={theme.dim}>No local models found.</Text>
|
|
330
|
+
) : (
|
|
331
|
+
<Select
|
|
332
|
+
options={options}
|
|
333
|
+
maxVisible={10}
|
|
334
|
+
onSubmit={value => {
|
|
335
|
+
const target = targets.find(item => `${item.kind}:${item.id}` === value)
|
|
336
|
+
if (target) setState({ kind: 'localUninstallConfirm', data: state.data, target })
|
|
337
|
+
}}
|
|
338
|
+
onCancel={() => setState({ kind: 'list', data: state.data })}
|
|
339
|
+
/>
|
|
340
|
+
)}
|
|
341
|
+
</Surface>
|
|
342
|
+
)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (state.kind === 'localUninstallConfirm') {
|
|
346
|
+
const modelName = state.target.displayName
|
|
347
|
+
return (
|
|
348
|
+
<Surface title="Confirm Uninstall" subtitle={modelName} footer="enter selects · esc returns to model list">
|
|
349
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
350
|
+
<Text color={theme.dim}>{localUninstallBoundaryCopy(state.target)}</Text>
|
|
351
|
+
<Text color={theme.dim}>Runner binaries are left unchanged.</Text>
|
|
352
|
+
</Box>
|
|
353
|
+
<Select<'confirm' | 'back'>
|
|
354
|
+
options={[
|
|
355
|
+
{ value: 'confirm', label: 'uninstall local model' },
|
|
356
|
+
{ value: 'back', label: 'back' },
|
|
357
|
+
]}
|
|
358
|
+
onSubmit={choice => {
|
|
359
|
+
if (choice === 'confirm') void uninstallLocalModel(state, setState)
|
|
360
|
+
else setState({ kind: 'localUninstallPick', data: state.data })
|
|
361
|
+
}}
|
|
362
|
+
onCancel={() => setState({ kind: 'localUninstallPick', data: state.data })}
|
|
363
|
+
/>
|
|
364
|
+
</Surface>
|
|
365
|
+
)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (state.kind === 'localUninstalling') {
|
|
369
|
+
return (
|
|
370
|
+
<Surface
|
|
371
|
+
title="Uninstalling Local Model"
|
|
372
|
+
subtitle={state.target.displayName}
|
|
373
|
+
>
|
|
374
|
+
<Spinner label="removing local model..." />
|
|
375
|
+
</Surface>
|
|
376
|
+
)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (state.kind === 'localUninstallDone') {
|
|
380
|
+
return (
|
|
381
|
+
<Surface title="Local Model Uninstalled" subtitle={state.modelName} footer="enter returns to picker · esc closes">
|
|
382
|
+
<Select<'back'>
|
|
383
|
+
options={[{ value: 'back', label: 'back to picker' }]}
|
|
384
|
+
onSubmit={() => setState({ kind: 'list', data: state.data })}
|
|
385
|
+
onCancel={() => setState({ kind: 'list', data: state.data })}
|
|
386
|
+
/>
|
|
387
|
+
</Surface>
|
|
388
|
+
)
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (state.kind === 'localUninstallError') {
|
|
392
|
+
return (
|
|
393
|
+
<Surface title="Could Not Uninstall Local Model" subtitle={state.message} tone="error" footer="enter selects · esc returns to picker">
|
|
394
|
+
<Select<'retry' | 'back'>
|
|
395
|
+
options={[
|
|
396
|
+
{ value: 'retry', label: 'try again' },
|
|
397
|
+
{ value: 'back', label: 'back to picker' },
|
|
398
|
+
]}
|
|
399
|
+
onSubmit={choice => {
|
|
400
|
+
if (choice === 'retry') void uninstallLocalModel({ kind: 'localUninstallConfirm', data: state.data, target: state.target }, setState)
|
|
401
|
+
else setState({ kind: 'list', data: state.data })
|
|
402
|
+
}}
|
|
403
|
+
onCancel={() => setState({ kind: 'list', data: state.data })}
|
|
404
|
+
/>
|
|
405
|
+
</Surface>
|
|
406
|
+
)
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (state.kind === 'localRunnerSetup') {
|
|
410
|
+
return (
|
|
411
|
+
<Surface
|
|
412
|
+
title="Install Local Runner"
|
|
413
|
+
subtitle="This model is downloaded. Install the local runner once to start it here."
|
|
414
|
+
footer="enter selects · esc returns to picker"
|
|
415
|
+
>
|
|
416
|
+
<Box flexDirection="column" marginBottom={1}>
|
|
417
|
+
<Text color={theme.dim}>Ethagent tried to start {friendlyFileName(state.model.filename)} automatically.</Text>
|
|
418
|
+
<Text color={theme.dim}>After this one-time install, downloaded local models start automatically.</Text>
|
|
419
|
+
<Text color={theme.dim}>Advanced: paste an existing llama-server path or run a compatible server at {DEFAULT_LLAMA_HOST}.</Text>
|
|
420
|
+
</Box>
|
|
421
|
+
<Select<'install' | 'path' | 'back' | 'download'>
|
|
422
|
+
options={[
|
|
423
|
+
{ value: 'install', label: 'install local runner' },
|
|
424
|
+
{ value: 'path', label: 'use existing runner path' },
|
|
425
|
+
{ value: 'back', label: 'back to picker' },
|
|
426
|
+
{ value: 'download', label: 'add another local model' },
|
|
427
|
+
]}
|
|
428
|
+
onSubmit={choice => {
|
|
429
|
+
if (choice === 'download') setState({ kind: 'hfInput', data: state.data })
|
|
430
|
+
else if (choice === 'install') void installRunnerAndStart(state, setState, onPick)
|
|
431
|
+
else if (choice === 'path') setState({ kind: 'localRunnerPathEntry', data: state.data, model: state.model, submitting: false })
|
|
432
|
+
else setState({ kind: 'list', data: state.data })
|
|
433
|
+
}}
|
|
434
|
+
onCancel={() => setState({ kind: 'list', data: state.data })}
|
|
435
|
+
/>
|
|
436
|
+
</Surface>
|
|
437
|
+
)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (state.kind === 'localRunnerInstalling') {
|
|
441
|
+
return (
|
|
442
|
+
<Surface title="Installing Local Runner" subtitle="This may take a few minutes.">
|
|
443
|
+
<ElapsedSpinner startedAt={state.startedAt} label={state.progress.label} />
|
|
444
|
+
<ProgressBar progress={state.progress.progress} variant="rainbow" />
|
|
445
|
+
</Surface>
|
|
446
|
+
)
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (state.kind === 'localRunnerInstallFail') {
|
|
450
|
+
const options = buildRunnerRecoveryOptions(state.result)
|
|
451
|
+
return (
|
|
452
|
+
<Surface title="Runner Setup Needs Attention" subtitle={state.result.message} tone="error" footer="enter selects · esc returns to picker">
|
|
453
|
+
<Select<'retry' | 'build' | 'path' | 'back'>
|
|
454
|
+
options={options}
|
|
455
|
+
onSubmit={choice => {
|
|
456
|
+
if (choice === 'retry') void installRunnerAndStart({ kind: 'localRunnerSetup', data: state.data, model: state.model }, setState, onPick)
|
|
457
|
+
else if (choice === 'build') void buildRunnerAndStart({ kind: 'localRunnerSetup', data: state.data, model: state.model }, setState, onPick)
|
|
458
|
+
else if (choice === 'path') setState({ kind: 'localRunnerPathEntry', data: state.data, model: state.model, submitting: false })
|
|
459
|
+
else setState({ kind: 'list', data: state.data })
|
|
460
|
+
}}
|
|
461
|
+
onCancel={() => setState({ kind: 'list', data: state.data })}
|
|
462
|
+
/>
|
|
463
|
+
</Surface>
|
|
464
|
+
)
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (state.kind === 'localRunnerPathEntry') {
|
|
468
|
+
return (
|
|
469
|
+
<Surface
|
|
470
|
+
title="Runner Path"
|
|
471
|
+
subtitle="Paste the full path to llama-server."
|
|
472
|
+
footer="enter saves · esc returns to install"
|
|
473
|
+
>
|
|
474
|
+
{state.submitting ? (
|
|
475
|
+
<Spinner label="checking runner path..." />
|
|
476
|
+
) : (
|
|
477
|
+
<TextInput
|
|
478
|
+
label="llama-server"
|
|
479
|
+
placeholder={runnerPathPlaceholder()}
|
|
480
|
+
onSubmit={value => void saveRunnerPathAndStart(state, value, setState, onPick)}
|
|
481
|
+
onCancel={() => setState({ kind: 'localRunnerSetup', data: state.data, model: state.model })}
|
|
482
|
+
/>
|
|
483
|
+
)}
|
|
484
|
+
{state.error ? <Text color="#e87070">{state.error}</Text> : null}
|
|
485
|
+
</Surface>
|
|
486
|
+
)
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (state.kind === 'localRunnerStarting') {
|
|
490
|
+
return (
|
|
491
|
+
<Surface title="Starting Local Model" subtitle={state.model.displayName}>
|
|
492
|
+
<ElapsedSpinner startedAt={state.startedAt} label="starting local runner" />
|
|
493
|
+
</Surface>
|
|
494
|
+
)
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (state.kind === 'localRunnerStartFail') {
|
|
498
|
+
return (
|
|
499
|
+
<Surface title="Local Model Failed to Start" subtitle={localRunnerStartFailureSubtitle(state.result)} tone="error" footer="enter selects · esc returns to picker">
|
|
500
|
+
<Select<'retry' | 'path' | 'install' | 'back'>
|
|
501
|
+
options={[
|
|
502
|
+
{ value: 'retry', label: 'try again' },
|
|
503
|
+
{ value: 'path', label: 'use existing runner path' },
|
|
504
|
+
{ value: 'install', label: 'install local runner' },
|
|
505
|
+
{ value: 'back', label: 'back to picker' },
|
|
506
|
+
]}
|
|
507
|
+
onSubmit={choice => {
|
|
508
|
+
if (choice === 'retry') void startAndPickHfModel(state.model, { kind: 'hfDone', data: state.data, model: state.model }, setState, onPick)
|
|
509
|
+
else if (choice === 'path') setState({ kind: 'localRunnerPathEntry', data: state.data, model: state.model, submitting: false })
|
|
510
|
+
else if (choice === 'install') void installRunnerAndStart({ kind: 'localRunnerSetup', data: state.data, model: state.model }, setState, onPick)
|
|
511
|
+
else setState({ kind: 'list', data: state.data })
|
|
512
|
+
}}
|
|
513
|
+
onCancel={() => setState({ kind: 'list', data: state.data })}
|
|
514
|
+
/>
|
|
515
|
+
</Surface>
|
|
516
|
+
)
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (state.kind === 'keyEntry') {
|
|
520
|
+
const { provider, action, submitting, error } = state
|
|
521
|
+
return (
|
|
522
|
+
<Surface
|
|
523
|
+
title={`${capitalize(action)} ${provider} API Key`}
|
|
524
|
+
subtitle="Stored in your OS keyring when available; never written to config in plaintext."
|
|
525
|
+
footer="enter saves · esc returns to picker"
|
|
526
|
+
>
|
|
527
|
+
{submitting ? (
|
|
528
|
+
<Spinner label={`saving ${provider} key...`} />
|
|
529
|
+
) : (
|
|
530
|
+
<TextInput
|
|
531
|
+
label={`${provider} key`}
|
|
532
|
+
placeholder={providerKeyPlaceholder(provider)}
|
|
533
|
+
isSecret
|
|
534
|
+
onSubmit={(value) => void submitKey(state, value, currentConfig, setState)}
|
|
535
|
+
onCancel={() => setState({ kind: 'list', data: state.data })}
|
|
536
|
+
/>
|
|
537
|
+
)}
|
|
538
|
+
{error ? <Text color="#e87070">{error}</Text> : null}
|
|
539
|
+
</Surface>
|
|
540
|
+
)
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (state.kind === 'keyManage') {
|
|
544
|
+
const { provider, submitting, error } = state
|
|
545
|
+
return (
|
|
546
|
+
<Surface
|
|
547
|
+
title={`${capitalize(provider)} API Key`}
|
|
548
|
+
subtitle="Manage the stored key for this provider."
|
|
549
|
+
footer="enter selects · esc returns to picker"
|
|
550
|
+
>
|
|
551
|
+
{submitting ? (
|
|
552
|
+
<Spinner label={`removing ${provider} key...`} />
|
|
553
|
+
) : (
|
|
554
|
+
<Select
|
|
555
|
+
options={[
|
|
556
|
+
{ value: 'edit', label: 'replace stored api key' },
|
|
557
|
+
{ value: 'delete', label: 'remove stored api key' },
|
|
558
|
+
{ value: 'cancel', label: 'back' },
|
|
559
|
+
]}
|
|
560
|
+
onSubmit={(value) => {
|
|
561
|
+
if (value === 'edit') {
|
|
562
|
+
setState({ kind: 'keyEntry', provider, action: 'edit', data: state.data, submitting: false })
|
|
563
|
+
return
|
|
564
|
+
}
|
|
565
|
+
if (value === 'cancel') {
|
|
566
|
+
setState({ kind: 'list', data: state.data })
|
|
567
|
+
return
|
|
568
|
+
}
|
|
569
|
+
void deleteKey(state, currentConfig, setState)
|
|
570
|
+
}}
|
|
571
|
+
onCancel={() => setState({ kind: 'list', data: state.data })}
|
|
572
|
+
/>
|
|
573
|
+
)}
|
|
574
|
+
{error ? <Text color="#e87070">{error}</Text> : null}
|
|
575
|
+
</Surface>
|
|
576
|
+
)
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
if (state.kind === 'catalog') {
|
|
582
|
+
const catalog = state.data.cloudCatalogs[state.provider]
|
|
583
|
+
const options = buildCatalogOptions(state.provider, catalog, currentProvider, currentModel, contextFit)
|
|
584
|
+
const initialIndex = options.findIndex(opt => {
|
|
585
|
+
if (opt.disabled) return false
|
|
586
|
+
const parsed = parseFullCatalogValue(opt.value)
|
|
587
|
+
return parsed?.provider === currentProvider && parsed.model === currentModel
|
|
588
|
+
})
|
|
589
|
+
return (
|
|
590
|
+
<Surface
|
|
591
|
+
title={`${capitalize(state.provider)} Full Catalog`}
|
|
592
|
+
subtitle={contextFit ? contextFitSubtitle(contextFit) : 'All discovered models for this provider'}
|
|
593
|
+
footer="enter selects · esc returns to picker"
|
|
594
|
+
>
|
|
595
|
+
<Select
|
|
596
|
+
options={options}
|
|
597
|
+
initialIndex={initialIndex === -1 ? 0 : initialIndex}
|
|
598
|
+
maxVisible={12}
|
|
599
|
+
onSubmit={(value) => {
|
|
600
|
+
const parsed = parseFullCatalogValue(value)
|
|
601
|
+
if (parsed) onPick({ kind: 'cloud', provider: parsed.provider, model: parsed.model, keyJustSet: false })
|
|
602
|
+
}}
|
|
603
|
+
onCancel={() => setState({ kind: 'list', data: state.data })}
|
|
604
|
+
/>
|
|
605
|
+
</Surface>
|
|
606
|
+
)
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if (state.kind === 'localCatalogLoading') {
|
|
610
|
+
return (
|
|
611
|
+
<Surface title="view full catalog" subtitle="loading files from configured hugging face repo.">
|
|
612
|
+
<Spinner label="reading hugging face files..." />
|
|
613
|
+
</Surface>
|
|
614
|
+
)
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
if (state.kind === 'localCatalogError') {
|
|
618
|
+
return (
|
|
619
|
+
<Surface title="view full catalog failed" subtitle={state.message} tone="error" footer="enter selects · esc returns to picker">
|
|
620
|
+
<Select<'retry' | 'paste' | 'back'>
|
|
621
|
+
options={[
|
|
622
|
+
{ value: 'retry', label: 'retry catalog' },
|
|
623
|
+
{ value: 'paste', label: 'paste a GGUF link' },
|
|
624
|
+
{ value: 'back', label: 'back to picker' },
|
|
625
|
+
]}
|
|
626
|
+
onSubmit={choice => {
|
|
627
|
+
if (choice === 'retry') void openLocalCatalog(state.data, setState)
|
|
628
|
+
else if (choice === 'paste') setState({ kind: 'hfInput', data: state.data })
|
|
629
|
+
else setState({ kind: 'list', data: state.data })
|
|
630
|
+
}}
|
|
631
|
+
onCancel={() => setState({ kind: 'list', data: state.data })}
|
|
632
|
+
/>
|
|
633
|
+
</Surface>
|
|
634
|
+
)
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
if (state.kind === 'localCatalog') {
|
|
638
|
+
const options = buildLocalModelCatalogOptions(state.data, { currentProvider, currentModel, contextFit }, state.catalog)
|
|
639
|
+
const initialIndex = localModelOptionIndex(options, currentProvider, currentModel)
|
|
640
|
+
return (
|
|
641
|
+
<Surface
|
|
642
|
+
title="view full catalog"
|
|
643
|
+
subtitle="one recommendation for this machine + install status"
|
|
644
|
+
footer="enter selects · esc returns to picker"
|
|
645
|
+
>
|
|
646
|
+
<Select
|
|
647
|
+
options={options}
|
|
648
|
+
initialIndex={initialIndex === -1 ? 0 : initialIndex}
|
|
649
|
+
maxVisible={12}
|
|
650
|
+
onSubmit={(value) => handleSubmit(value, state, setState, onPick)}
|
|
651
|
+
onCancel={() => setState({ kind: 'list', data: state.data })}
|
|
652
|
+
/>
|
|
653
|
+
</Surface>
|
|
654
|
+
)
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const { data } = state
|
|
658
|
+
const options = buildModelPickerOptions(data, { currentProvider, currentModel, contextFit })
|
|
659
|
+
const initialIndex = localOrCloudOptionIndex(options, currentProvider, currentModel)
|
|
660
|
+
|
|
661
|
+
return (
|
|
662
|
+
<Surface
|
|
663
|
+
title={contextFit ? 'Switch to Larger-Context Model' : 'Switch Provider / Model'}
|
|
664
|
+
subtitle={contextFit ? contextFitSubtitle(contextFit) : 'Downloaded GGUF files + cloud providers'}
|
|
665
|
+
footer="enter selects · esc closes · /models lists installed models"
|
|
666
|
+
>
|
|
667
|
+
<Select
|
|
668
|
+
options={options}
|
|
669
|
+
initialIndex={initialIndex === -1 ? 0 : initialIndex}
|
|
670
|
+
maxVisible={10}
|
|
671
|
+
onSubmit={(value) => handleSubmit(value, state, setState, onPick)}
|
|
672
|
+
onCancel={onCancel}
|
|
673
|
+
/>
|
|
674
|
+
</Surface>
|
|
675
|
+
)
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function handleSubmit(
|
|
679
|
+
value: string,
|
|
680
|
+
state: Extract<State, { kind: 'list' | 'localCatalog' }>,
|
|
681
|
+
setState: (s: State) => void,
|
|
682
|
+
onPick: (sel: ModelPickerSelection) => void,
|
|
683
|
+
): void {
|
|
684
|
+
if (value.startsWith('hdr:')) return
|
|
685
|
+
if (value.startsWith('hf:')) {
|
|
686
|
+
const id = value.slice(3)
|
|
687
|
+
if (id === 'download') {
|
|
688
|
+
setState({ kind: 'hfInput', data: state.data })
|
|
689
|
+
return
|
|
690
|
+
}
|
|
691
|
+
const model = state.data.hfModels.find(item => item.id === id)
|
|
692
|
+
if (!model) return
|
|
693
|
+
void (async () => {
|
|
694
|
+
const local = await findLocalHfModel(id)
|
|
695
|
+
if (!local) {
|
|
696
|
+
setState({ kind: 'hfError', data: state.data, message: 'local model metadata was not found' })
|
|
697
|
+
return
|
|
698
|
+
}
|
|
699
|
+
await startAndPickHfModel(local, state, setState, onPick)
|
|
700
|
+
})()
|
|
701
|
+
return
|
|
702
|
+
}
|
|
703
|
+
if (value.startsWith('uc:') && state.kind === 'localCatalog') {
|
|
704
|
+
const entry = state.catalog.find(item => catalogOptionValue(item.repo.repoId, item.file.filename) === value)
|
|
705
|
+
if (entry) void reviewCatalogModel(state, entry, setState)
|
|
706
|
+
return
|
|
707
|
+
}
|
|
708
|
+
if (value === 'local:uninstall') {
|
|
709
|
+
setState({ kind: 'localUninstallPick', data: state.data })
|
|
710
|
+
return
|
|
711
|
+
}
|
|
712
|
+
if (value === 'local:catalog') {
|
|
713
|
+
void openLocalCatalog(state.data, setState)
|
|
714
|
+
return
|
|
715
|
+
}
|
|
716
|
+
if (value.startsWith('key:')) {
|
|
717
|
+
const parsed = parseKeyValue(value)
|
|
718
|
+
if (!parsed) return
|
|
719
|
+
if (parsed.action === 'manage') {
|
|
720
|
+
setState({ kind: 'keyManage', provider: parsed.provider, data: state.data, submitting: false })
|
|
721
|
+
return
|
|
722
|
+
}
|
|
723
|
+
setState({ kind: 'keyEntry', provider: parsed.provider, action: parsed.action, data: state.data, submitting: false })
|
|
724
|
+
return
|
|
725
|
+
}
|
|
726
|
+
if (value.startsWith('catalog:')) {
|
|
727
|
+
const provider = value.slice('catalog:'.length)
|
|
728
|
+
if (isCloudProvider(provider)) setState({ kind: 'catalog', provider, data: state.data })
|
|
729
|
+
return
|
|
730
|
+
}
|
|
731
|
+
if (value.startsWith('c:')) {
|
|
732
|
+
const parsed = parseCloudValue(value)
|
|
733
|
+
if (parsed) {
|
|
734
|
+
onPick({ kind: 'cloud', provider: parsed.provider, model: parsed.model, keyJustSet: false })
|
|
735
|
+
return
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
async function openLocalCatalog(
|
|
741
|
+
data: LoadedData,
|
|
742
|
+
setState: (s: State) => void,
|
|
743
|
+
): Promise<void> {
|
|
744
|
+
setState({ kind: 'localCatalogLoading', data })
|
|
745
|
+
try {
|
|
746
|
+
const installedModels = await loadLocalHfModels()
|
|
747
|
+
const catalog = await fetchUncensoredGgufCatalog({
|
|
748
|
+
machineSpec: data.machineSpec,
|
|
749
|
+
installedModels,
|
|
750
|
+
})
|
|
751
|
+
setState({
|
|
752
|
+
kind: 'localCatalog',
|
|
753
|
+
data: { ...data, hfModels: await loadHfPickerModels() },
|
|
754
|
+
catalog,
|
|
755
|
+
})
|
|
756
|
+
} catch (err: unknown) {
|
|
757
|
+
setState({ kind: 'localCatalogError', data, message: (err as Error).message })
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
async function reviewCatalogModel(
|
|
762
|
+
state: Extract<State, { kind: 'localCatalog' }>,
|
|
763
|
+
entry: UncensoredCatalogEntry,
|
|
764
|
+
setState: (s: State) => void,
|
|
765
|
+
): Promise<void> {
|
|
766
|
+
const files = ggufFiles(entry.repo)
|
|
767
|
+
const installed = chooseInstalledHfModelForRepo(
|
|
768
|
+
await loadLocalHfModels(),
|
|
769
|
+
entry.repo,
|
|
770
|
+
files,
|
|
771
|
+
entry.file.filename,
|
|
772
|
+
state.data.machineSpec,
|
|
773
|
+
)
|
|
774
|
+
if (installed) {
|
|
775
|
+
setState({
|
|
776
|
+
kind: 'hfDone',
|
|
777
|
+
data: { ...state.data, hfModels: await loadHfPickerModels() },
|
|
778
|
+
model: installed,
|
|
779
|
+
alreadyInstalled: true,
|
|
780
|
+
})
|
|
781
|
+
return
|
|
782
|
+
}
|
|
783
|
+
try {
|
|
784
|
+
const plan = await createHfDownloadPlan(entry.repo.repoId, entry.file.filename)
|
|
785
|
+
setState({ kind: 'hfReview', data: state.data, plan })
|
|
786
|
+
} catch (err: unknown) {
|
|
787
|
+
setState({ kind: 'hfError', data: state.data, message: (err as Error).message, input: entry.repo.repoId })
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function buildCatalogOptions(
|
|
792
|
+
provider: CloudProviderId,
|
|
793
|
+
catalog: ModelCatalogResult | undefined,
|
|
794
|
+
currentProvider: ProviderId,
|
|
795
|
+
currentModel: string,
|
|
796
|
+
contextFit?: ModelPickerContextFit | null,
|
|
797
|
+
): SelectOption<string>[] {
|
|
798
|
+
if (!catalog || catalog.entries.length === 0) {
|
|
799
|
+
return [{
|
|
800
|
+
value: `hdr:catalog-empty:${provider}`,
|
|
801
|
+
label: 'no models found',
|
|
802
|
+
disabled: true,
|
|
803
|
+
role: 'notice',
|
|
804
|
+
prefix: 'note',
|
|
805
|
+
}]
|
|
806
|
+
}
|
|
807
|
+
const sourceById = new Map(catalog.entries.map(entry => [entry.id, entry.source]))
|
|
808
|
+
return orderModelsForContextFit(provider, catalog.entries.map(entry => entry.id), contextFit).map(id => {
|
|
809
|
+
const active = currentProvider === provider && currentModel === id
|
|
810
|
+
const suffix = sourceById.get(id) === 'fallback' ? ' fallback' : ''
|
|
811
|
+
const displayName = formatModelDisplayName(provider, id, { maxLength: 64 })
|
|
812
|
+
return {
|
|
813
|
+
value: `full:${provider}:${id}`,
|
|
814
|
+
label: contextFitLabel(provider, id, `${displayName}${active ? ' *' : ''}${suffix}`, contextFit),
|
|
815
|
+
role: 'option',
|
|
816
|
+
}
|
|
817
|
+
})
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function parseCloudValue(value: string): { provider: CloudProviderId; model: string } | null {
|
|
821
|
+
if (!value.startsWith('c:')) return null
|
|
822
|
+
const rest = value.slice(2)
|
|
823
|
+
const sep = rest.indexOf(':')
|
|
824
|
+
if (sep === -1) return null
|
|
825
|
+
const provider = rest.slice(0, sep)
|
|
826
|
+
const model = rest.slice(sep + 1)
|
|
827
|
+
if (!isCloudProvider(provider) || !model) return null
|
|
828
|
+
return { provider, model }
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function localModelOptionIndex(
|
|
832
|
+
options: SelectOption<string>[],
|
|
833
|
+
currentProvider: ProviderId,
|
|
834
|
+
currentModel: string,
|
|
835
|
+
): number {
|
|
836
|
+
return options.findIndex(opt => {
|
|
837
|
+
if (opt.disabled) return false
|
|
838
|
+
if (opt.value.startsWith('hf:')) return opt.value.slice(3) === currentModel && currentProvider === 'llamacpp'
|
|
839
|
+
if (opt.value.startsWith('uc:')) return opt.value.slice(3) === currentModel && currentProvider === 'llamacpp'
|
|
840
|
+
return false
|
|
841
|
+
})
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function localOrCloudOptionIndex(
|
|
845
|
+
options: SelectOption<string>[],
|
|
846
|
+
currentProvider: ProviderId,
|
|
847
|
+
currentModel: string,
|
|
848
|
+
): number {
|
|
849
|
+
return options.findIndex(opt => {
|
|
850
|
+
if (opt.disabled) return false
|
|
851
|
+
if (opt.value.startsWith('hf:')) return opt.value.slice(3) === currentModel && currentProvider === 'llamacpp'
|
|
852
|
+
if (opt.value.startsWith('uc:')) return opt.value.slice(3) === currentModel && currentProvider === 'llamacpp'
|
|
853
|
+
const cloud = parseCloudValue(opt.value)
|
|
854
|
+
return cloud?.provider === currentProvider && cloud.model === currentModel
|
|
855
|
+
})
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function parseFullCatalogValue(value: string): { provider: CloudProviderId; model: string } | null {
|
|
859
|
+
if (!value.startsWith('full:')) return null
|
|
860
|
+
const rest = value.slice(5)
|
|
861
|
+
const sep = rest.indexOf(':')
|
|
862
|
+
if (sep === -1) return null
|
|
863
|
+
const provider = rest.slice(0, sep)
|
|
864
|
+
const model = rest.slice(sep + 1)
|
|
865
|
+
if (!isCloudProvider(provider) || !model) return null
|
|
866
|
+
return { provider, model }
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function parseKeyValue(value: string): { action: 'set' | 'edit' | 'manage'; provider: CloudProviderId } | null {
|
|
870
|
+
if (!value.startsWith('key:')) return null
|
|
871
|
+
const parts = value.split(':')
|
|
872
|
+
if (parts.length !== 3) return null
|
|
873
|
+
const action = parts[1]
|
|
874
|
+
const provider = parts[2]
|
|
875
|
+
if (action !== 'set' && action !== 'edit' && action !== 'manage') return null
|
|
876
|
+
if (!isCloudProvider(provider)) return null
|
|
877
|
+
return { action, provider }
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
async function submitKey(
|
|
881
|
+
state: Extract<State, { kind: 'keyEntry' }>,
|
|
882
|
+
value: string,
|
|
883
|
+
currentConfig: EthagentConfig,
|
|
884
|
+
setState: (s: State) => void,
|
|
885
|
+
): Promise<void> {
|
|
886
|
+
const trimmed = value.trim()
|
|
887
|
+
if (!trimmed) {
|
|
888
|
+
setState({ ...state, error: 'key cannot be empty' })
|
|
889
|
+
return
|
|
890
|
+
}
|
|
891
|
+
setState({ ...state, submitting: true, error: undefined })
|
|
892
|
+
try {
|
|
893
|
+
await setKey(state.provider, trimmed)
|
|
894
|
+
const data = await refreshProviderKeyState(state.data, currentConfig, state.provider)
|
|
895
|
+
setState({ kind: 'list', data })
|
|
896
|
+
} catch (err: unknown) {
|
|
897
|
+
setState({ ...state, submitting: false, error: (err as Error).message })
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
async function deleteKey(
|
|
902
|
+
state: Extract<State, { kind: 'keyManage' }>,
|
|
903
|
+
currentConfig: EthagentConfig,
|
|
904
|
+
setState: (s: State) => void,
|
|
905
|
+
): Promise<void> {
|
|
906
|
+
setState({ ...state, submitting: true, error: undefined })
|
|
907
|
+
try {
|
|
908
|
+
await rmKey(state.provider)
|
|
909
|
+
const data = await refreshProviderKeyState(state.data, currentConfig, state.provider)
|
|
910
|
+
setState({ kind: 'list', data })
|
|
911
|
+
} catch (err: unknown) {
|
|
912
|
+
setState({ ...state, submitting: false, error: (err as Error).message })
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
async function refreshProviderKeyState(
|
|
917
|
+
data: LoadedData,
|
|
918
|
+
currentConfig: EthagentConfig,
|
|
919
|
+
provider: CloudProviderId,
|
|
920
|
+
): Promise<LoadedData> {
|
|
921
|
+
clearModelCatalogCache()
|
|
922
|
+
const keySet = await hasKey(provider)
|
|
923
|
+
const cloudKeys = { ...data.cloudKeys, [provider]: keySet }
|
|
924
|
+
const cloudCatalogs = { ...data.cloudCatalogs }
|
|
925
|
+
if (keySet) {
|
|
926
|
+
cloudCatalogs[provider] = await discoverProviderModels(configForProvider(currentConfig, provider))
|
|
927
|
+
} else {
|
|
928
|
+
delete cloudCatalogs[provider]
|
|
929
|
+
}
|
|
930
|
+
return { ...data, cloudKeys, cloudCatalogs }
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
function configForProvider(config: EthagentConfig, provider: CloudProviderId): EthagentConfig {
|
|
934
|
+
return {
|
|
935
|
+
...config,
|
|
936
|
+
provider,
|
|
937
|
+
model: config.provider === provider ? config.model : defaultModelFor(provider),
|
|
938
|
+
baseUrl: provider === 'openai' && config.provider === 'openai' ? config.baseUrl : undefined,
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
export function buildHfFileOptions(
|
|
943
|
+
repo: HuggingFaceRepoInfo,
|
|
944
|
+
files: HuggingFaceSibling[],
|
|
945
|
+
spec: SpecSnapshot | undefined,
|
|
946
|
+
installedModelIds: string[] = [],
|
|
947
|
+
): SelectOption<string>[] {
|
|
948
|
+
const ordered = spec
|
|
949
|
+
? orderGgufFilesForSpec(repo, files, spec)
|
|
950
|
+
: files.map(file => ({ file, fit: 'unknown' as GgufMachineFit, score: 0, budgetBytes: 0 }))
|
|
951
|
+
const recommended = spec ? ordered[0]?.file.filename : undefined
|
|
952
|
+
const installed = new Set(installedModelIds)
|
|
953
|
+
return ordered.map(item => {
|
|
954
|
+
const size = item.file.sizeBytes ? formatBytes(item.file.sizeBytes) : ''
|
|
955
|
+
const indicators = [
|
|
956
|
+
item.file.filename === recommended ? 'recommended' : '',
|
|
957
|
+
installed.has(localModelId(repo.repoId, item.file.filename)) ? 'installed' : '',
|
|
958
|
+
]
|
|
959
|
+
return {
|
|
960
|
+
value: item.file.filename,
|
|
961
|
+
label: item.file.filename,
|
|
962
|
+
subtext: modelMetadataSubtext(size, indicators),
|
|
963
|
+
role: 'option' as const,
|
|
964
|
+
}
|
|
965
|
+
})
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
function buildRunnerRecoveryOptions(
|
|
969
|
+
result: Extract<LlamaCppInstallResult, { ok: false }>,
|
|
970
|
+
): SelectOption<'retry' | 'build' | 'path' | 'back'>[] {
|
|
971
|
+
const options: SelectOption<'retry' | 'build' | 'path' | 'back'>[] = []
|
|
972
|
+
if (result.recovery.includes('source-build')) {
|
|
973
|
+
options.push({
|
|
974
|
+
value: 'build',
|
|
975
|
+
label: 'build local runner',
|
|
976
|
+
hint: 'uses git and cmake if installed',
|
|
977
|
+
})
|
|
978
|
+
}
|
|
979
|
+
if (result.recovery.includes('runner-path')) {
|
|
980
|
+
options.push({ value: 'path', label: 'use existing runner path' })
|
|
981
|
+
}
|
|
982
|
+
if (result.recovery.includes('retry-install')) {
|
|
983
|
+
options.push({ value: 'retry', label: 'retry automatic install' })
|
|
984
|
+
}
|
|
985
|
+
options.push({ value: 'back', label: 'back to picker' })
|
|
986
|
+
return options
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
function localRunnerStartFailureSubtitle(result: Extract<LlamaCppStartResult, { ok: false }>): string {
|
|
990
|
+
switch (result.code) {
|
|
991
|
+
case 'readiness-timeout':
|
|
992
|
+
return 'the local runner is still loading or did not answer in time'
|
|
993
|
+
case 'runner-exited':
|
|
994
|
+
return 'the local runner closed before becoming ready'
|
|
995
|
+
case 'spawn-failed':
|
|
996
|
+
return 'the local runner could not be started'
|
|
997
|
+
case 'different-model-running':
|
|
998
|
+
return result.message
|
|
999
|
+
case 'model-file-missing':
|
|
1000
|
+
return result.message
|
|
1001
|
+
case 'runner-not-installed':
|
|
1002
|
+
return 'this machine still needs a local runner'
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
async function findInstalledHfModelForInput(input: string): Promise<LocalHfModel | null> {
|
|
1007
|
+
const ref = parseHuggingFaceRef(input)
|
|
1008
|
+
const installed = await loadLocalHfModels()
|
|
1009
|
+
return installed.find(model =>
|
|
1010
|
+
model.status === 'ready'
|
|
1011
|
+
&& model.repoId === ref.repoId
|
|
1012
|
+
&& (!ref.filename || model.filename === ref.filename)
|
|
1013
|
+
) ?? null
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
export function chooseInstalledHfModelForRepo(
|
|
1017
|
+
installed: LocalHfModel[],
|
|
1018
|
+
repo: HuggingFaceRepoInfo,
|
|
1019
|
+
files: HuggingFaceSibling[],
|
|
1020
|
+
requestedFilename: string | undefined,
|
|
1021
|
+
spec: SpecSnapshot | undefined,
|
|
1022
|
+
): LocalHfModel | null {
|
|
1023
|
+
const compatibleFiles = new Set(files.map(file => file.filename))
|
|
1024
|
+
const candidates = installed.filter(model =>
|
|
1025
|
+
model.status === 'ready'
|
|
1026
|
+
&& model.repoId === repo.repoId
|
|
1027
|
+
&& compatibleFiles.has(model.filename)
|
|
1028
|
+
&& (!requestedFilename || model.filename === requestedFilename)
|
|
1029
|
+
)
|
|
1030
|
+
if (requestedFilename || candidates.length <= 1) return candidates[0] ?? null
|
|
1031
|
+
|
|
1032
|
+
const orderedFiles = spec
|
|
1033
|
+
? orderGgufFilesForSpec(repo, files, spec).map(item => item.file.filename)
|
|
1034
|
+
: files.map(file => file.filename)
|
|
1035
|
+
for (const filename of orderedFiles) {
|
|
1036
|
+
const match = candidates.find(model => model.filename === filename)
|
|
1037
|
+
if (match) return match
|
|
1038
|
+
}
|
|
1039
|
+
return candidates[0] ?? null
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
async function inspectHfInput(
|
|
1043
|
+
state: Extract<State, { kind: 'hfInput' }>,
|
|
1044
|
+
value: string,
|
|
1045
|
+
setState: (s: State) => void,
|
|
1046
|
+
): Promise<void> {
|
|
1047
|
+
const input = value.trim()
|
|
1048
|
+
if (!input) {
|
|
1049
|
+
setState({ ...state, error: 'paste a model link or repo id' })
|
|
1050
|
+
return
|
|
1051
|
+
}
|
|
1052
|
+
setState({ kind: 'hfLoading', data: state.data, input })
|
|
1053
|
+
try {
|
|
1054
|
+
const ref = parseHuggingFaceRef(input)
|
|
1055
|
+
const repo = await fetchHuggingFaceRepoInfo(ref)
|
|
1056
|
+
const files = ggufFiles(repo)
|
|
1057
|
+
if (files.length === 0) {
|
|
1058
|
+
setState({
|
|
1059
|
+
kind: 'hfInput',
|
|
1060
|
+
data: state.data,
|
|
1061
|
+
error: 'no compatible local model files found; paste a different model link',
|
|
1062
|
+
})
|
|
1063
|
+
return
|
|
1064
|
+
}
|
|
1065
|
+
const installed = chooseInstalledHfModelForRepo(
|
|
1066
|
+
await loadLocalHfModels(),
|
|
1067
|
+
repo,
|
|
1068
|
+
files,
|
|
1069
|
+
ref.filename,
|
|
1070
|
+
state.data.machineSpec,
|
|
1071
|
+
)
|
|
1072
|
+
if (installed) {
|
|
1073
|
+
setState({
|
|
1074
|
+
kind: 'hfDone',
|
|
1075
|
+
data: { ...state.data, hfModels: await loadHfPickerModels() },
|
|
1076
|
+
model: installed,
|
|
1077
|
+
alreadyInstalled: true,
|
|
1078
|
+
})
|
|
1079
|
+
return
|
|
1080
|
+
}
|
|
1081
|
+
const recommendedFilename = state.data.machineSpec
|
|
1082
|
+
? recommendGgufFile(repo, files, state.data.machineSpec)?.file.filename
|
|
1083
|
+
: files[0]?.filename
|
|
1084
|
+
if (ref.filename || files.length === 1) {
|
|
1085
|
+
const plan = await createHfDownloadPlan(input, ref.filename ?? recommendedFilename)
|
|
1086
|
+
setState({ kind: 'hfReview', data: state.data, plan })
|
|
1087
|
+
return
|
|
1088
|
+
}
|
|
1089
|
+
setState({ kind: 'hfFilePick', data: state.data, input, repo, files })
|
|
1090
|
+
} catch (err: unknown) {
|
|
1091
|
+
setState({ kind: 'hfInput', data: state.data, error: (err as Error).message })
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
async function reviewHfFile(
|
|
1096
|
+
state: Extract<State, { kind: 'hfFilePick' }>,
|
|
1097
|
+
filename: string,
|
|
1098
|
+
setState: (s: State) => void,
|
|
1099
|
+
): Promise<void> {
|
|
1100
|
+
setState({ kind: 'hfLoading', data: state.data, input: state.input })
|
|
1101
|
+
try {
|
|
1102
|
+
const installed = chooseInstalledHfModelForRepo(
|
|
1103
|
+
await loadLocalHfModels(),
|
|
1104
|
+
state.repo,
|
|
1105
|
+
state.files,
|
|
1106
|
+
filename,
|
|
1107
|
+
state.data.machineSpec,
|
|
1108
|
+
)
|
|
1109
|
+
if (installed) {
|
|
1110
|
+
setState({
|
|
1111
|
+
kind: 'hfDone',
|
|
1112
|
+
data: { ...state.data, hfModels: await loadHfPickerModels() },
|
|
1113
|
+
model: installed,
|
|
1114
|
+
alreadyInstalled: true,
|
|
1115
|
+
})
|
|
1116
|
+
return
|
|
1117
|
+
}
|
|
1118
|
+
const plan = await createHfDownloadPlan(state.input, filename)
|
|
1119
|
+
setState({ kind: 'hfReview', data: state.data, plan })
|
|
1120
|
+
} catch (err: unknown) {
|
|
1121
|
+
setState({ kind: 'hfError', data: state.data, message: (err as Error).message, input: state.input })
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
async function startHfDownload(
|
|
1126
|
+
state: Extract<State, { kind: 'hfReview' }>,
|
|
1127
|
+
setState: (s: State) => void,
|
|
1128
|
+
abortRef: React.MutableRefObject<AbortController | null>,
|
|
1129
|
+
onPick: (sel: ModelPickerSelection) => void,
|
|
1130
|
+
): Promise<void> {
|
|
1131
|
+
const controller = new AbortController()
|
|
1132
|
+
abortRef.current = controller
|
|
1133
|
+
setState({ kind: 'hfDownloading', data: state.data, plan: state.plan, progress: { status: 'starting', completed: 0, total: state.plan.sizeBytes } })
|
|
1134
|
+
try {
|
|
1135
|
+
for await (const progress of downloadHfModel(state.plan, controller.signal)) {
|
|
1136
|
+
if (controller.signal.aborted) return
|
|
1137
|
+
setState({ kind: 'hfDownloading', data: state.data, plan: state.plan, progress })
|
|
1138
|
+
}
|
|
1139
|
+
const model = await findLocalHfModel(`${state.plan.repoId}#${state.plan.filename}`)
|
|
1140
|
+
?? modelFromPlan(state.plan, undefined, 'ready')
|
|
1141
|
+
const data = {
|
|
1142
|
+
...state.data,
|
|
1143
|
+
hfModels: await loadHfPickerModels(),
|
|
1144
|
+
}
|
|
1145
|
+
await startAndPickHfModel(model, { kind: 'hfDone', data, model }, setState, onPick)
|
|
1146
|
+
} catch (err: unknown) {
|
|
1147
|
+
if (controller.signal.aborted) return
|
|
1148
|
+
setState({ kind: 'hfError', data: state.data, message: (err as Error).message, input: state.plan.repoId })
|
|
1149
|
+
} finally {
|
|
1150
|
+
abortRef.current = null
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
function localUninstallTargets(data: LoadedData): LocalUninstallTarget[] {
|
|
1155
|
+
return data.hfModels.map(model => ({
|
|
1156
|
+
kind: 'hf' as const,
|
|
1157
|
+
id: model.id,
|
|
1158
|
+
displayName: formatLocalHfModelDisplayName(model.id, {
|
|
1159
|
+
displayName: model.displayName,
|
|
1160
|
+
maxLength: 64,
|
|
1161
|
+
}),
|
|
1162
|
+
sizeBytes: model.sizeBytes,
|
|
1163
|
+
}))
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
function isCurrentLocalUninstallTarget(
|
|
1167
|
+
target: LocalUninstallTarget,
|
|
1168
|
+
currentProvider: ProviderId,
|
|
1169
|
+
currentModel: string,
|
|
1170
|
+
): boolean {
|
|
1171
|
+
return target.kind === 'hf' && currentProvider === 'llamacpp' && target.id === currentModel
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
function localUninstallBoundaryCopy(_target: LocalUninstallTarget): string {
|
|
1175
|
+
return 'This removes only the downloaded GGUF file and metadata from this machine.'
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
async function uninstallLocalModel(
|
|
1179
|
+
state: Extract<State, { kind: 'localUninstallConfirm' }>,
|
|
1180
|
+
setState: (s: State) => void,
|
|
1181
|
+
): Promise<void> {
|
|
1182
|
+
setState({ kind: 'localUninstalling', data: state.data, target: state.target })
|
|
1183
|
+
const modelName = state.target.displayName
|
|
1184
|
+
try {
|
|
1185
|
+
await uninstallLocalHfModel(state.target.id)
|
|
1186
|
+
const data = await refreshLocalModelData(state.data)
|
|
1187
|
+
setState({ kind: 'localUninstallDone', data, modelName })
|
|
1188
|
+
} catch (err: unknown) {
|
|
1189
|
+
setState({
|
|
1190
|
+
kind: 'localUninstallError',
|
|
1191
|
+
data: state.data,
|
|
1192
|
+
target: state.target,
|
|
1193
|
+
message: (err as Error).message,
|
|
1194
|
+
})
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
async function refreshLocalModelData(data: LoadedData): Promise<LoadedData> {
|
|
1199
|
+
const hfModels = await loadHfPickerModels()
|
|
1200
|
+
return {
|
|
1201
|
+
...data,
|
|
1202
|
+
hfModels,
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
async function startAndPickHfModel(
|
|
1207
|
+
model: LocalHfModel,
|
|
1208
|
+
state: Extract<State, { kind: 'list' | 'localCatalog' | 'hfDone' }>,
|
|
1209
|
+
setState: (s: State) => void,
|
|
1210
|
+
onPick: (sel: ModelPickerSelection) => void,
|
|
1211
|
+
): Promise<void> {
|
|
1212
|
+
if (model.risk === 'high') {
|
|
1213
|
+
setState({ kind: 'hfError', data: state.data, message: 'blocked high-risk model; choose a model from a more credible source' })
|
|
1214
|
+
return
|
|
1215
|
+
}
|
|
1216
|
+
setState({ kind: 'localRunnerStarting', data: state.data, model, startedAt: Date.now() })
|
|
1217
|
+
const result = await startLlamaCppServer({
|
|
1218
|
+
modelPath: model.localPath,
|
|
1219
|
+
modelAlias: model.id,
|
|
1220
|
+
})
|
|
1221
|
+
const llamaCpp = await probeLlamaCpp()
|
|
1222
|
+
const data = { ...state.data, llamaCpp }
|
|
1223
|
+
if (!result.ok) {
|
|
1224
|
+
if (result.code === 'runner-not-installed') {
|
|
1225
|
+
setState({ kind: 'localRunnerSetup', data, model })
|
|
1226
|
+
return
|
|
1227
|
+
}
|
|
1228
|
+
setState({ kind: 'localRunnerStartFail', data, model, result })
|
|
1229
|
+
return
|
|
1230
|
+
}
|
|
1231
|
+
onPick({ kind: 'llamacpp', model: model.id })
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
async function installRunnerAndStart(
|
|
1235
|
+
state: Extract<State, { kind: 'localRunnerSetup' }>,
|
|
1236
|
+
setState: (s: State) => void,
|
|
1237
|
+
onPick: (sel: ModelPickerSelection) => void,
|
|
1238
|
+
): Promise<void> {
|
|
1239
|
+
await runRunnerSetup(state, setState, onPick, installLlamaCppRunner)
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
async function buildRunnerAndStart(
|
|
1243
|
+
state: Extract<State, { kind: 'localRunnerSetup' }>,
|
|
1244
|
+
setState: (s: State) => void,
|
|
1245
|
+
onPick: (sel: ModelPickerSelection) => void,
|
|
1246
|
+
): Promise<void> {
|
|
1247
|
+
await runRunnerSetup(state, setState, onPick, buildLlamaCppRunner)
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
async function runRunnerSetup(
|
|
1251
|
+
state: Extract<State, { kind: 'localRunnerSetup' }>,
|
|
1252
|
+
setState: (s: State) => void,
|
|
1253
|
+
onPick: (sel: ModelPickerSelection) => void,
|
|
1254
|
+
setup: (onProgress?: (progress: LlamaCppInstallProgress) => void) => Promise<LlamaCppInstallResult>,
|
|
1255
|
+
): Promise<void> {
|
|
1256
|
+
const startedAt = Date.now()
|
|
1257
|
+
const initialProgress: LlamaCppInstallProgress = {
|
|
1258
|
+
phase: 'checking',
|
|
1259
|
+
label: 'preparing local runner',
|
|
1260
|
+
progress: 0.04,
|
|
1261
|
+
}
|
|
1262
|
+
const updateProgress = (progress: LlamaCppInstallProgress): void => {
|
|
1263
|
+
setState({ kind: 'localRunnerInstalling', data: state.data, model: state.model, startedAt, progress })
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
setState({ kind: 'localRunnerInstalling', data: state.data, model: state.model, startedAt, progress: initialProgress })
|
|
1267
|
+
const result = await setup(updateProgress)
|
|
1268
|
+
if (!result.ok) {
|
|
1269
|
+
setState({ kind: 'localRunnerInstallFail', data: state.data, model: state.model, result })
|
|
1270
|
+
return
|
|
1271
|
+
}
|
|
1272
|
+
await startAndPickHfModel(state.model, { kind: 'hfDone', data: state.data, model: state.model }, setState, onPick)
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
async function saveRunnerPathAndStart(
|
|
1276
|
+
state: Extract<State, { kind: 'localRunnerPathEntry' }>,
|
|
1277
|
+
value: string,
|
|
1278
|
+
setState: (s: State) => void,
|
|
1279
|
+
onPick: (sel: ModelPickerSelection) => void,
|
|
1280
|
+
): Promise<void> {
|
|
1281
|
+
const runnerPath = value.trim().replace(/^"|"$/g, '')
|
|
1282
|
+
if (!runnerPath) {
|
|
1283
|
+
setState({ ...state, error: 'paste the full path to llama-server' })
|
|
1284
|
+
return
|
|
1285
|
+
}
|
|
1286
|
+
setState({ ...state, submitting: true, error: undefined })
|
|
1287
|
+
try {
|
|
1288
|
+
await setLlamaCppServerPath(runnerPath)
|
|
1289
|
+
await startAndPickHfModel(state.model, { kind: 'hfDone', data: state.data, model: state.model }, setState, onPick)
|
|
1290
|
+
} catch (err: unknown) {
|
|
1291
|
+
setState({ ...state, submitting: false, error: (err as Error).message })
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
function contextFitSubtitle(contextFit: ModelPickerContextFit): string {
|
|
1296
|
+
const threshold = contextFit.thresholdPercent ?? 90
|
|
1297
|
+
return `pending prompt needs ~${formatTokens(contextFit.usedTokens)} tokens; choose a model under ${threshold}% or use /compact.`
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
function contextFitLabel(
|
|
1301
|
+
provider: ProviderId,
|
|
1302
|
+
model: string,
|
|
1303
|
+
baseLabel: string,
|
|
1304
|
+
contextFit?: ModelPickerContextFit | null,
|
|
1305
|
+
): string {
|
|
1306
|
+
if (!contextFit) return baseLabel
|
|
1307
|
+
const info = contextWindowInfo(provider, model)
|
|
1308
|
+
const percent = info.tokens > 0 ? Math.round((contextFit.usedTokens / info.tokens) * 100) : 0
|
|
1309
|
+
return `${baseLabel} ${formatContextWindow(info.tokens)} ctx ${percent}%`
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
function formatTokens(count: number): string {
|
|
1313
|
+
if (count < 1000) return String(count)
|
|
1314
|
+
if (count < 10_000) return `${(count / 1000).toFixed(1)}k`
|
|
1315
|
+
return `${Math.round(count / 1000)}k`
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
function formatContextWindow(tokens: number): string {
|
|
1319
|
+
if (tokens >= 1_000_000) {
|
|
1320
|
+
const millions = tokens / 1_000_000
|
|
1321
|
+
return Number.isInteger(millions) ? `${millions}m` : `${millions.toFixed(1)}m`
|
|
1322
|
+
}
|
|
1323
|
+
if (tokens >= 1000) return `${Math.round(tokens / 1000)}k`
|
|
1324
|
+
return String(tokens)
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
async function loadHfPickerModels(): Promise<ModelPickerOptionsData['hfModels']> {
|
|
1328
|
+
const installed = await loadLocalHfModels()
|
|
1329
|
+
return installed.map(model => ({
|
|
1330
|
+
id: model.id,
|
|
1331
|
+
displayName: model.displayName,
|
|
1332
|
+
sizeBytes: model.sizeBytes,
|
|
1333
|
+
quantization: model.quantization,
|
|
1334
|
+
risk: model.risk,
|
|
1335
|
+
task: model.task,
|
|
1336
|
+
status: model.status,
|
|
1337
|
+
}))
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
async function probeLlamaCpp(): Promise<ModelPickerOptionsData['llamaCpp']> {
|
|
1341
|
+
try {
|
|
1342
|
+
const status = await detectLlamaCpp()
|
|
1343
|
+
return {
|
|
1344
|
+
binaryPresent: status.binaryPresent,
|
|
1345
|
+
serverUp: status.serverUp,
|
|
1346
|
+
}
|
|
1347
|
+
} catch (err: unknown) {
|
|
1348
|
+
return { binaryPresent: false, serverUp: false, error: (err as Error).message }
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
function formatBytes(bytes: number): string {
|
|
1353
|
+
if (bytes <= 0) return 'size unknown'
|
|
1354
|
+
const gb = bytes / 1e9
|
|
1355
|
+
if (gb >= 1) return `${gb.toFixed(1)} GB`
|
|
1356
|
+
return `${Math.round(bytes / 1e6)} MB`
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
function modelMetadataSubtext(size: string, indicators: string[]): string | undefined {
|
|
1360
|
+
return [size, ...indicators].filter(Boolean).join(' · ') || undefined
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
function riskColor(risk: string): string {
|
|
1364
|
+
if (risk === 'high') return '#e87070'
|
|
1365
|
+
if (risk === 'medium') return theme.dim
|
|
1366
|
+
return theme.accentSecondary
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
function fitColor(fit: GgufMachineFit): string {
|
|
1370
|
+
if (fit === 'too-large') return '#e87070'
|
|
1371
|
+
if (fit === 'tight') return theme.accentWarm
|
|
1372
|
+
return theme.dim
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
function fitLabel(fit: GgufMachineFit, recommended: boolean): string {
|
|
1376
|
+
if (recommended && fit !== 'too-large') return 'recommended for this machine'
|
|
1377
|
+
if (recommended) return 'best match found; may be too large'
|
|
1378
|
+
return fileFitHint(fit)
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
function fileFitHint(fit: GgufMachineFit): string {
|
|
1382
|
+
switch (fit) {
|
|
1383
|
+
case 'fits': return 'fits this machine'
|
|
1384
|
+
case 'tight': return 'may be slow or tight on memory'
|
|
1385
|
+
case 'too-large': return 'likely too large for this machine'
|
|
1386
|
+
case 'unknown': return 'machine fit unknown'
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
function formatSignals(downloads: number | undefined, likes: number | undefined): string {
|
|
1391
|
+
const d = downloads == null ? 'downloads unknown' : `${downloads} downloads`
|
|
1392
|
+
const l = likes == null ? 'likes unknown' : `${likes} likes`
|
|
1393
|
+
return `${d}, ${l}`
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
function friendlyFileName(filename: string): string {
|
|
1397
|
+
return filename.split('/').pop() ?? filename
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
function safetyLabel(risk: HfRisk): string {
|
|
1401
|
+
if (risk === 'low') return 'reviewed'
|
|
1402
|
+
if (risk === 'medium') return 'needs review'
|
|
1403
|
+
return 'blocked'
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
function credibilityLabel(credibility: HfCredibility): string {
|
|
1407
|
+
if (credibility === 'established') return 'established'
|
|
1408
|
+
if (credibility === 'normal') return 'some signals'
|
|
1409
|
+
return 'limited signals'
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
function friendlyReasons(reasons: string[]): string[] {
|
|
1413
|
+
return reasons.map(reason => {
|
|
1414
|
+
if (reason.includes('compatible local model file')) return 'compatible local model file'
|
|
1415
|
+
if (reason.includes('selected file is not compatible')) return 'file is not compatible with local chat'
|
|
1416
|
+
if (reason.includes('revision is mutable')) return 'model link may point to changing files'
|
|
1417
|
+
if (reason.includes('license is missing')) return 'license is missing'
|
|
1418
|
+
if (reason.includes('limited public usage signals')) return 'source has limited public usage'
|
|
1419
|
+
if (reason.includes('pickle/bin')) return 'repo also contains risky model file formats'
|
|
1420
|
+
return reason
|
|
1421
|
+
})
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
function providerKeyPlaceholder(provider: ProviderId): string {
|
|
1425
|
+
if (provider === 'openai') return 'sk-...'
|
|
1426
|
+
if (provider === 'anthropic') return 'sk-ant-...'
|
|
1427
|
+
if (provider === 'gemini') return 'AIza...'
|
|
1428
|
+
return ''
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
function runnerPathPlaceholder(): string {
|
|
1432
|
+
if (process.platform === 'win32') return 'C:\\path\\to\\llama-server.exe'
|
|
1433
|
+
return '/path/to/llama-server'
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
function capitalize(value: string): string {
|
|
1437
|
+
return value.charAt(0).toUpperCase() + value.slice(1)
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
function isCloudProvider(value: string | undefined): value is CloudProviderId {
|
|
1441
|
+
return value === 'openai' || value === 'anthropic' || value === 'gemini'
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
const ElapsedSpinner: React.FC<{ startedAt: number; label: string }> = ({ startedAt, label }) => {
|
|
1445
|
+
return <Spinner label={label} startedAt={startedAt} />
|
|
1446
|
+
}
|