ethagent 3.0.1 → 3.1.0
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/README.md +6 -1
- package/package.json +3 -1
- package/src/app/FirstRun.tsx +1 -24
- package/src/app/firstRunConfig.ts +26 -0
- package/src/auth/openaiOAuth/landingPage.ts +2 -11
- package/src/chat/ChatScreen.tsx +32 -117
- package/src/chat/MessageList.tsx +18 -260
- package/src/chat/chatEnvironment.ts +16 -0
- package/src/chat/chatTurnContext.ts +50 -0
- package/src/chat/chatTurnOrchestrator.ts +5 -112
- package/src/chat/chatTurnRows.ts +64 -0
- package/src/chat/commands.ts +3 -178
- package/src/chat/continuityEditReview.ts +42 -0
- package/src/chat/input/ChatInput.tsx +10 -144
- package/src/chat/input/chatInputHelpers.ts +62 -0
- package/src/chat/input/inputRendering.tsx +93 -0
- package/src/chat/messageMarkdown.ts +220 -0
- package/src/chat/messageRows.ts +43 -0
- package/src/chat/planImplementation.ts +62 -0
- package/src/chat/slashCommandHandlers.ts +165 -0
- package/src/chat/slashCommandViews.ts +120 -0
- package/src/cli/main.tsx +7 -0
- package/src/identity/continuity/challenges.ts +123 -0
- package/src/identity/continuity/envelope.ts +49 -1484
- package/src/identity/continuity/envelopeCreate.ts +322 -0
- package/src/identity/continuity/envelopeCrypto.ts +182 -0
- package/src/identity/continuity/envelopeParse.ts +441 -0
- package/src/identity/continuity/envelopeTypes.ts +204 -0
- package/src/identity/continuity/envelopeVersion.ts +1 -0
- package/src/identity/continuity/payloadNormalization.ts +183 -0
- package/src/identity/continuity/publicSkills.ts +5 -5
- package/src/identity/continuity/skills/loadSkills.ts +12 -69
- package/src/identity/continuity/skills/skillPaths.ts +76 -0
- package/src/identity/continuity/skillsNormalization.ts +119 -0
- package/src/identity/continuity/snapshotToken.ts +28 -0
- package/src/identity/hub/continuity/completion.ts +67 -0
- package/src/identity/hub/continuity/effects.ts +5 -62
- package/src/identity/hub/profile/effects.ts +6 -170
- package/src/identity/hub/profile/operatorSave.ts +202 -0
- package/src/identity/registry/erc8004/metadata.ts +31 -23
- package/src/identity/wallet/browserWallet/html.ts +1 -57
- package/src/identity/wallet/browserWallet/walletPageSource.ts +85 -0
- package/src/identity/wallet/page/controller.ts +1 -1
- package/src/identity/wallet/page/errorView.ts +122 -0
- package/src/identity/wallet/page/view.ts +3 -114
- package/src/mcp/manager.ts +8 -66
- package/src/mcp/managerHelpers.ts +70 -0
- package/src/models/ModelPicker.tsx +69 -889
- package/src/models/huggingface.ts +20 -137
- package/src/models/huggingfaceStorage.ts +136 -0
- package/src/models/llamacpp.ts +37 -303
- package/src/models/llamacppCommands.ts +44 -0
- package/src/models/llamacppConfig.ts +34 -0
- package/src/models/llamacppDiscovery.ts +176 -0
- package/src/models/llamacppOutput.ts +65 -0
- package/src/models/modelPickerCatalogFlow.ts +56 -0
- package/src/models/modelPickerCredentials.ts +166 -0
- package/src/models/modelPickerData.ts +41 -0
- package/src/models/modelPickerDisplay.tsx +132 -0
- package/src/models/modelPickerHfFlow.ts +192 -0
- package/src/models/modelPickerLocalRunnerFlow.ts +115 -0
- package/src/models/modelPickerTypes.ts +69 -0
- package/src/models/modelPickerUninstallFlow.ts +48 -0
- package/src/models/modelPickerViewHelpers.ts +174 -0
- package/src/providers/openai-chat.ts +5 -124
- package/src/providers/openaiChatWire.ts +124 -0
- package/src/runtime/providerTurn.ts +38 -0
- package/src/runtime/textToolParser.ts +161 -0
- package/src/runtime/toolIntent.ts +1 -1
- package/src/runtime/turn.ts +43 -499
- package/src/runtime/turnNudges.ts +223 -0
- package/src/runtime/turnTypes.ts +86 -0
- package/src/ui/terminalTitle.ts +30 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { LlamaCppInstallPlan, LlamaCppInstallResult } from './llamacpp.js'
|
|
2
|
+
|
|
3
|
+
type RunInstallResult = { ok: true } | { ok: false; message: string; detail?: string }
|
|
4
|
+
|
|
5
|
+
export function summarizeInstallOutput(output: string): string | undefined {
|
|
6
|
+
const lines = output
|
|
7
|
+
.split(/\r?\n/)
|
|
8
|
+
.map(cleanInstallLine)
|
|
9
|
+
.filter(Boolean)
|
|
10
|
+
.filter(line => !/^[\-\\|/_.=\s]+$/.test(line))
|
|
11
|
+
.filter(line => !/^\d+(\.\d+)?\s*(B|KB|MB|GB)\s*\/\s*\d+/i.test(line))
|
|
12
|
+
const unique = [...new Set(lines)]
|
|
13
|
+
return unique.slice(-6).join('\n') || undefined
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function humanInstallError(plan: LlamaCppInstallPlan, code: number | null): string {
|
|
17
|
+
if (plan.command === 'winget') return 'Windows could not install the local runner automatically.'
|
|
18
|
+
if (plan.command === 'brew') return 'Homebrew could not install the local runner automatically.'
|
|
19
|
+
if (plan.command === 'nix') return 'Nix could not install the local runner automatically.'
|
|
20
|
+
if (plan.command === 'port') return 'MacPorts could not install the local runner automatically.'
|
|
21
|
+
if (plan.command === 'git') return 'ethagent could not download the local runner source.'
|
|
22
|
+
if (plan.command === 'cmake') return 'ethagent could not build the local runner.'
|
|
23
|
+
return code === null
|
|
24
|
+
? `${plan.label} did not complete.`
|
|
25
|
+
: `${plan.label} failed with exit code ${code}.`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function installFailureDetail(code: number | null, output: string): string | undefined {
|
|
29
|
+
const details = [
|
|
30
|
+
code === null ? undefined : `exit code ${code}`,
|
|
31
|
+
summarizeInstallOutput(output),
|
|
32
|
+
].filter((item): item is string => Boolean(item))
|
|
33
|
+
return details.join('\n') || undefined
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function cleanInstallLine(line: string): string {
|
|
37
|
+
return line
|
|
38
|
+
.replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, '')
|
|
39
|
+
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '')
|
|
40
|
+
.replace(/\s+/g, ' ')
|
|
41
|
+
.trim()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function installerProgressLabel(plan: LlamaCppInstallPlan): string {
|
|
45
|
+
if (plan.command === 'winget') return 'installing with Windows package manager...'
|
|
46
|
+
if (plan.command === 'brew') return 'installing with Homebrew...'
|
|
47
|
+
if (plan.command === 'nix') return 'installing with Nix...'
|
|
48
|
+
if (plan.command === 'port') return 'installing with MacPorts...'
|
|
49
|
+
return `installing with ${plan.label}...`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function formatInstallFailure(label: string, result: RunInstallResult): string {
|
|
53
|
+
if (result.ok) return label
|
|
54
|
+
return [label, result.message, result.detail].filter(Boolean).join(': ')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function buildFailure(result: RunInstallResult): LlamaCppInstallResult {
|
|
58
|
+
return {
|
|
59
|
+
ok: false,
|
|
60
|
+
code: 'build-failed',
|
|
61
|
+
message: 'ethagent could not build the local runner.',
|
|
62
|
+
detail: result.ok ? undefined : [result.message, result.detail].filter(Boolean).join('\n'),
|
|
63
|
+
recovery: ['runner-path', 'retry-install', 'back'],
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { createHfDownloadPlan, ggufFiles, loadLocalHfModels } from './huggingface.js'
|
|
2
|
+
import { fetchUncensoredGgufCatalog, type UncensoredCatalogEntry } from './uncensoredCatalog.js'
|
|
3
|
+
import { chooseInstalledHfModelForRepo } from './modelPickerHfFlow.js'
|
|
4
|
+
import { loadHfPickerModels } from './modelPickerData.js'
|
|
5
|
+
import type { LoadedModelPickerData as LoadedData, ModelPickerState as State } from './modelPickerTypes.js'
|
|
6
|
+
|
|
7
|
+
export async function openLocalCatalog(
|
|
8
|
+
data: LoadedData,
|
|
9
|
+
setState: (s: State) => void,
|
|
10
|
+
): Promise<void> {
|
|
11
|
+
setState({ kind: 'localCatalogLoading', data })
|
|
12
|
+
try {
|
|
13
|
+
const installedModels = await loadLocalHfModels()
|
|
14
|
+
const catalog = await fetchUncensoredGgufCatalog({
|
|
15
|
+
machineSpec: data.machineSpec,
|
|
16
|
+
installedModels,
|
|
17
|
+
})
|
|
18
|
+
setState({
|
|
19
|
+
kind: 'localCatalog',
|
|
20
|
+
data: { ...data, hfModels: await loadHfPickerModels() },
|
|
21
|
+
catalog,
|
|
22
|
+
})
|
|
23
|
+
} catch (err: unknown) {
|
|
24
|
+
setState({ kind: 'localCatalogError', data, message: (err as Error).message })
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function reviewCatalogModel(
|
|
29
|
+
state: Extract<State, { kind: 'localCatalog' }>,
|
|
30
|
+
entry: UncensoredCatalogEntry,
|
|
31
|
+
setState: (s: State) => void,
|
|
32
|
+
): Promise<void> {
|
|
33
|
+
const files = ggufFiles(entry.repo)
|
|
34
|
+
const installed = chooseInstalledHfModelForRepo(
|
|
35
|
+
await loadLocalHfModels(),
|
|
36
|
+
entry.repo,
|
|
37
|
+
files,
|
|
38
|
+
entry.file.filename,
|
|
39
|
+
state.data.machineSpec,
|
|
40
|
+
)
|
|
41
|
+
if (installed) {
|
|
42
|
+
setState({
|
|
43
|
+
kind: 'hfDone',
|
|
44
|
+
data: { ...state.data, hfModels: await loadHfPickerModels() },
|
|
45
|
+
model: installed,
|
|
46
|
+
alreadyInstalled: true,
|
|
47
|
+
})
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
const plan = await createHfDownloadPlan(entry.repo.repoId, entry.file.filename)
|
|
52
|
+
setState({ kind: 'hfReview', data: state.data, plan })
|
|
53
|
+
} catch (err: unknown) {
|
|
54
|
+
setState({ kind: 'hfError', data: state.data, message: (err as Error).message, input: entry.repo.repoId })
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import type React from 'react'
|
|
2
|
+
import { OpenAIOAuthService } from '../auth/openaiOAuth/index.js'
|
|
3
|
+
import { hasOpenAIOAuthCredentials, rmOpenAIOAuthCredentials } from '../auth/openaiOAuth/credentials.js'
|
|
4
|
+
import { openExternalUrl } from '../utils/openExternal.js'
|
|
5
|
+
import { hasKey, rmKey, setKey } from '../storage/secrets.js'
|
|
6
|
+
import type { EthagentConfig, ProviderId } from '../storage/config.js'
|
|
7
|
+
import {
|
|
8
|
+
clearModelCatalogCache,
|
|
9
|
+
discoverProviderModels,
|
|
10
|
+
isOpenAIOAuthAllowedModel,
|
|
11
|
+
OPENAI_OAUTH_DEFAULT_MODEL,
|
|
12
|
+
} from './catalog.js'
|
|
13
|
+
import type { CloudCredentialKind, CloudProviderId } from './modelPickerOptions.js'
|
|
14
|
+
import type {
|
|
15
|
+
LoadedModelPickerData as LoadedData,
|
|
16
|
+
ModelPickerSelection,
|
|
17
|
+
ModelPickerState as State,
|
|
18
|
+
} from './modelPickerTypes.js'
|
|
19
|
+
import { configForProvider, pickFallbackSelection } from './modelPickerViewHelpers.js'
|
|
20
|
+
|
|
21
|
+
export async function submitKey(
|
|
22
|
+
state: Extract<State, { kind: 'keyEntry' }>,
|
|
23
|
+
value: string,
|
|
24
|
+
currentConfig: EthagentConfig,
|
|
25
|
+
setState: (s: State) => void,
|
|
26
|
+
): Promise<void> {
|
|
27
|
+
const trimmed = value.trim()
|
|
28
|
+
if (!trimmed) {
|
|
29
|
+
setState({ ...state, error: 'key cannot be empty' })
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
setState({ ...state, submitting: true, error: undefined })
|
|
33
|
+
try {
|
|
34
|
+
await setKey(state.provider, trimmed)
|
|
35
|
+
const data = await refreshProviderKeyState(state.data, currentConfig, state.provider)
|
|
36
|
+
setState({ kind: 'list', data })
|
|
37
|
+
} catch (err: unknown) {
|
|
38
|
+
setState({ ...state, submitting: false, error: (err as Error).message })
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function startOpenAIOAuthFlow(
|
|
43
|
+
data: LoadedData,
|
|
44
|
+
currentConfig: EthagentConfig,
|
|
45
|
+
setState: (s: State) => void,
|
|
46
|
+
serviceRef: React.MutableRefObject<OpenAIOAuthService | null>,
|
|
47
|
+
onPick: (sel: ModelPickerSelection) => void,
|
|
48
|
+
): Promise<void> {
|
|
49
|
+
serviceRef.current?.cleanup()
|
|
50
|
+
const service = new OpenAIOAuthService()
|
|
51
|
+
serviceRef.current = service
|
|
52
|
+
setState({ kind: 'oauthLogin', data, phase: 'waiting' })
|
|
53
|
+
try {
|
|
54
|
+
const result = await service.start(authUrl => {
|
|
55
|
+
openExternalUrl(authUrl)
|
|
56
|
+
setState({ kind: 'oauthLogin', data, phase: 'waiting', url: authUrl })
|
|
57
|
+
})
|
|
58
|
+
if (serviceRef.current !== service) return
|
|
59
|
+
setState({ kind: 'oauthLogin', data, phase: 'exchanging' })
|
|
60
|
+
if (result.kind === 'apikey') {
|
|
61
|
+
if (typeof result.apiKey !== 'string' || result.apiKey.length === 0) {
|
|
62
|
+
throw new Error(`OAuth result was apikey kind but apiKey is ${typeof result.apiKey}; refusing to store.`)
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
await setKey('openai', result.apiKey)
|
|
66
|
+
} catch (err) {
|
|
67
|
+
throw new Error(`Storing the OpenAI API key failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
let refreshed: LoadedData
|
|
71
|
+
try {
|
|
72
|
+
refreshed = await refreshProviderKeyState(data, currentConfig, 'openai')
|
|
73
|
+
} catch (err) {
|
|
74
|
+
throw new Error(`Refreshing the OpenAI provider state failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
75
|
+
}
|
|
76
|
+
if (serviceRef.current !== service) return
|
|
77
|
+
serviceRef.current = null
|
|
78
|
+
if (result.kind === 'oauth-only' && !isOpenAIOAuthAllowedModel(currentConfig.model)) {
|
|
79
|
+
onPick({ kind: 'cloud', provider: 'openai', model: OPENAI_OAUTH_DEFAULT_MODEL, keyJustSet: true })
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
setState({ kind: 'list', data: refreshed })
|
|
83
|
+
} catch (err: unknown) {
|
|
84
|
+
if (serviceRef.current !== service) return
|
|
85
|
+
serviceRef.current = null
|
|
86
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
87
|
+
if (message === 'OpenAI sign-in was cancelled.') {
|
|
88
|
+
setState({ kind: 'list', data })
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
setState({ kind: 'oauthLogin', data, phase: 'error', message })
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function deleteKey(
|
|
96
|
+
state: Extract<State, { kind: 'keyManage' }>,
|
|
97
|
+
currentConfig: EthagentConfig,
|
|
98
|
+
setState: (s: State) => void,
|
|
99
|
+
onPick: (sel: ModelPickerSelection) => void,
|
|
100
|
+
currentProvider: ProviderId,
|
|
101
|
+
): Promise<void> {
|
|
102
|
+
setState({ ...state, submitting: true, error: undefined })
|
|
103
|
+
try {
|
|
104
|
+
await rmKey(state.provider)
|
|
105
|
+
if (state.provider === 'openai') await rmOpenAIOAuthCredentials()
|
|
106
|
+
const data = await refreshProviderKeyState(state.data, currentConfig, state.provider)
|
|
107
|
+
if (currentProvider === state.provider) {
|
|
108
|
+
const fallback = pickFallbackSelection(data, state.provider)
|
|
109
|
+
if (fallback) {
|
|
110
|
+
onPick(fallback)
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
setState({ kind: 'list', data })
|
|
115
|
+
} catch (err: unknown) {
|
|
116
|
+
setState({ ...state, submitting: false, error: (err as Error).message })
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function signOutOAuth(
|
|
121
|
+
state: Extract<State, { kind: 'oauthManage' }>,
|
|
122
|
+
currentConfig: EthagentConfig,
|
|
123
|
+
setState: (s: State) => void,
|
|
124
|
+
onPick: (sel: ModelPickerSelection) => void,
|
|
125
|
+
currentProvider: ProviderId,
|
|
126
|
+
): Promise<void> {
|
|
127
|
+
setState({ ...state, submitting: true, error: undefined })
|
|
128
|
+
try {
|
|
129
|
+
await rmKey('openai')
|
|
130
|
+
await rmOpenAIOAuthCredentials()
|
|
131
|
+
const data = await refreshProviderKeyState(state.data, currentConfig, 'openai')
|
|
132
|
+
if (currentProvider === 'openai') {
|
|
133
|
+
const fallback = pickFallbackSelection(data, 'openai')
|
|
134
|
+
if (fallback) {
|
|
135
|
+
onPick(fallback)
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
setState({ kind: 'list', data })
|
|
140
|
+
} catch (err: unknown) {
|
|
141
|
+
setState({ ...state, submitting: false, error: (err as Error).message })
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function refreshProviderKeyState(
|
|
146
|
+
data: LoadedData,
|
|
147
|
+
currentConfig: EthagentConfig,
|
|
148
|
+
provider: CloudProviderId,
|
|
149
|
+
): Promise<LoadedData> {
|
|
150
|
+
clearModelCatalogCache()
|
|
151
|
+
const apiKeySet = await hasKey(provider)
|
|
152
|
+
const oauthSet = provider === 'openai' ? await hasOpenAIOAuthCredentials() : false
|
|
153
|
+
const keySet = apiKeySet || oauthSet
|
|
154
|
+
const cloudKeys = { ...data.cloudKeys, [provider]: keySet }
|
|
155
|
+
const cloudCredentialKinds: Partial<Record<ProviderId, CloudCredentialKind>> = { ...(data.cloudCredentialKinds ?? {}) }
|
|
156
|
+
if (oauthSet) cloudCredentialKinds[provider] = 'oauth'
|
|
157
|
+
else if (apiKeySet) cloudCredentialKinds[provider] = 'apikey'
|
|
158
|
+
else delete cloudCredentialKinds[provider]
|
|
159
|
+
const cloudCatalogs = { ...data.cloudCatalogs }
|
|
160
|
+
if (keySet) {
|
|
161
|
+
cloudCatalogs[provider] = await discoverProviderModels(configForProvider(currentConfig, provider))
|
|
162
|
+
} else {
|
|
163
|
+
delete cloudCatalogs[provider]
|
|
164
|
+
}
|
|
165
|
+
return { ...data, cloudKeys, cloudCatalogs, cloudCredentialKinds }
|
|
166
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { detectLlamaCpp } from './llamacpp.js'
|
|
2
|
+
import { backfillMmprojForModels, loadLocalHfModels } from './huggingface.js'
|
|
3
|
+
import type { LoadedModelPickerData } from './modelPickerTypes.js'
|
|
4
|
+
import type { ModelPickerOptionsData } from './modelPickerOptions.js'
|
|
5
|
+
|
|
6
|
+
export async function loadHfPickerModels(): Promise<ModelPickerOptionsData['hfModels']> {
|
|
7
|
+
const installed = await loadLocalHfModels()
|
|
8
|
+
const backfilled = await backfillMmprojForModels(installed)
|
|
9
|
+
return backfilled.map(model => ({
|
|
10
|
+
id: model.id,
|
|
11
|
+
displayName: model.displayName,
|
|
12
|
+
sizeBytes: model.sizeBytes,
|
|
13
|
+
quantization: model.quantization,
|
|
14
|
+
risk: model.risk,
|
|
15
|
+
task: model.task,
|
|
16
|
+
status: model.status,
|
|
17
|
+
mmprojPath: model.mmprojPath,
|
|
18
|
+
mmprojAvailable: model.mmprojAvailable,
|
|
19
|
+
mmprojSizeBytes: model.mmprojSizeBytes,
|
|
20
|
+
}))
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function probeLlamaCpp(): Promise<ModelPickerOptionsData['llamaCpp']> {
|
|
24
|
+
try {
|
|
25
|
+
const status = await detectLlamaCpp()
|
|
26
|
+
return {
|
|
27
|
+
binaryPresent: status.binaryPresent,
|
|
28
|
+
serverUp: status.serverUp,
|
|
29
|
+
}
|
|
30
|
+
} catch (err: unknown) {
|
|
31
|
+
return { binaryPresent: false, serverUp: false, error: (err as Error).message }
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function refreshLocalModelData(data: LoadedModelPickerData): Promise<LoadedModelPickerData> {
|
|
36
|
+
const hfModels = await loadHfPickerModels()
|
|
37
|
+
return {
|
|
38
|
+
...data,
|
|
39
|
+
hfModels,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Spinner } from '../ui/Spinner.js'
|
|
3
|
+
import { theme } from '../ui/theme.js'
|
|
4
|
+
import { contextWindowInfo } from '../runtime/compaction.js'
|
|
5
|
+
import type { ProviderId } from '../storage/config.js'
|
|
6
|
+
import type { HfCredibility, HfRisk } from './huggingface.js'
|
|
7
|
+
import type { GgufMachineFit } from './modelRecommendation.js'
|
|
8
|
+
import type { CloudProviderId, ModelPickerContextFit } from './modelPickerOptions.js'
|
|
9
|
+
|
|
10
|
+
export function contextFitSubtitle(contextFit: ModelPickerContextFit): string {
|
|
11
|
+
const threshold = contextFit.thresholdPercent ?? 90
|
|
12
|
+
return `pending prompt needs ~${formatTokens(contextFit.usedTokens)} tokens; choose a model under ${threshold}% or use /compact.`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function contextFitLabel(
|
|
16
|
+
provider: ProviderId,
|
|
17
|
+
model: string,
|
|
18
|
+
baseLabel: string,
|
|
19
|
+
contextFit?: ModelPickerContextFit | null,
|
|
20
|
+
): string {
|
|
21
|
+
if (!contextFit) return baseLabel
|
|
22
|
+
const info = contextWindowInfo(provider, model)
|
|
23
|
+
const percent = info.tokens > 0 ? Math.round((contextFit.usedTokens / info.tokens) * 100) : 0
|
|
24
|
+
return `${baseLabel} ${formatContextWindow(info.tokens)} ctx ${percent}%`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function formatTokens(count: number): string {
|
|
28
|
+
if (count < 1000) return String(count)
|
|
29
|
+
if (count < 10_000) return `${(count / 1000).toFixed(1)}k`
|
|
30
|
+
return `${Math.round(count / 1000)}k`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function formatContextWindow(tokens: number): string {
|
|
34
|
+
if (tokens >= 1_000_000) {
|
|
35
|
+
const millions = tokens / 1_000_000
|
|
36
|
+
return Number.isInteger(millions) ? `${millions}m` : `${millions.toFixed(1)}m`
|
|
37
|
+
}
|
|
38
|
+
if (tokens >= 1000) return `${Math.round(tokens / 1000)}k`
|
|
39
|
+
return String(tokens)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function formatBytes(bytes: number): string {
|
|
43
|
+
if (bytes <= 0) return 'size unknown'
|
|
44
|
+
const gb = bytes / 1e9
|
|
45
|
+
if (gb >= 1) return `${gb.toFixed(1)} GB`
|
|
46
|
+
return `${Math.round(bytes / 1e6)} MB`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function modelMetadataSubtext(size: string, indicators: string[]): string | undefined {
|
|
50
|
+
return [size, ...indicators].filter(Boolean).join(' · ') || undefined
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function riskColor(risk: string): string {
|
|
54
|
+
if (risk === 'high') return theme.accentError
|
|
55
|
+
if (risk === 'medium') return theme.dim
|
|
56
|
+
return theme.accentPeriwinkle
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function fitColor(fit: GgufMachineFit): string {
|
|
60
|
+
if (fit === 'too-large') return theme.accentError
|
|
61
|
+
if (fit === 'tight') return theme.accentPeriwinkle
|
|
62
|
+
return theme.dim
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function fitLabel(fit: GgufMachineFit, recommended: boolean): string {
|
|
66
|
+
if (recommended && fit !== 'too-large') return 'Recommended for this machine'
|
|
67
|
+
if (recommended) return 'Best match found; may be too large'
|
|
68
|
+
return fileFitHint(fit)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function fileFitHint(fit: GgufMachineFit): string {
|
|
72
|
+
switch (fit) {
|
|
73
|
+
case 'fits': return 'Fits this machine'
|
|
74
|
+
case 'tight': return 'May be slow or tight on memory'
|
|
75
|
+
case 'too-large': return 'Likely too large for this machine'
|
|
76
|
+
case 'unknown': return 'machine fit unknown'
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function formatSignals(downloads: number | undefined, likes: number | undefined): string {
|
|
81
|
+
const d = downloads == null ? 'downloads unknown' : `${downloads} downloads`
|
|
82
|
+
const l = likes == null ? 'likes unknown' : `${likes} likes`
|
|
83
|
+
return `${d}, ${l}`
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function friendlyFileName(filename: string): string {
|
|
87
|
+
return filename.split('/').pop() ?? filename
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function safetyLabel(risk: HfRisk): string {
|
|
91
|
+
if (risk === 'low') return 'reviewed'
|
|
92
|
+
if (risk === 'medium') return 'needs review'
|
|
93
|
+
return 'blocked'
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function credibilityLabel(credibility: HfCredibility): string {
|
|
97
|
+
if (credibility === 'established') return 'established'
|
|
98
|
+
if (credibility === 'normal') return 'some signals'
|
|
99
|
+
return 'limited signals'
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function friendlyReasons(reasons: string[]): string[] {
|
|
103
|
+
return reasons.map(reason => {
|
|
104
|
+
if (reason.includes('compatible local model file')) return 'compatible local model file'
|
|
105
|
+
if (reason.includes('selected file is not compatible')) return 'file is not compatible with local chat'
|
|
106
|
+
if (reason.includes('revision is mutable')) return 'model link may point to changing files'
|
|
107
|
+
if (reason.includes('license is missing')) return 'license is missing'
|
|
108
|
+
if (reason.includes('limited public usage signals')) return 'source has limited public usage'
|
|
109
|
+
if (reason.includes('pickle/bin')) return 'repo also contains risky model file formats'
|
|
110
|
+
return reason
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function providerKeyPlaceholder(provider: ProviderId): string {
|
|
115
|
+
if (provider === 'openai') return 'sk-...'
|
|
116
|
+
if (provider === 'anthropic') return 'sk-ant-...'
|
|
117
|
+
if (provider === 'gemini') return 'AIza...'
|
|
118
|
+
return ''
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function runnerPathPlaceholder(): string {
|
|
122
|
+
if (process.platform === 'win32') return 'C:\\path\\to\\llama-server.exe'
|
|
123
|
+
return '/path/to/llama-server'
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function isCloudProvider(value: string | undefined): value is CloudProviderId {
|
|
127
|
+
return value === 'openai' || value === 'anthropic' || value === 'gemini'
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export const ElapsedSpinner: React.FC<{ startedAt: number; label: string }> = ({ startedAt, label }) => {
|
|
131
|
+
return <Spinner label={label} startedAt={startedAt} />
|
|
132
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import {
|
|
3
|
+
addMmprojToInstalledModel,
|
|
4
|
+
createHfDownloadPlan,
|
|
5
|
+
downloadHfModel,
|
|
6
|
+
fetchHuggingFaceRepoInfo,
|
|
7
|
+
findLocalHfModel,
|
|
8
|
+
ggufFiles,
|
|
9
|
+
loadLocalHfModels,
|
|
10
|
+
modelFromPlan,
|
|
11
|
+
parseHuggingFaceRef,
|
|
12
|
+
type HuggingFaceRepoInfo,
|
|
13
|
+
type HuggingFaceSibling,
|
|
14
|
+
type LocalHfModel,
|
|
15
|
+
} from './huggingface.js'
|
|
16
|
+
import { stopLlamaCppServer } from './llamacpp.js'
|
|
17
|
+
import { orderGgufFilesForSpec, recommendGgufFile } from './modelRecommendation.js'
|
|
18
|
+
import type { SpecSnapshot } from './runtimeDetection.js'
|
|
19
|
+
import type { ModelPickerSelection, ModelPickerState as State } from './modelPickerTypes.js'
|
|
20
|
+
import { loadHfPickerModels } from './modelPickerData.js'
|
|
21
|
+
import { startAndPickHfModel } from './modelPickerLocalRunnerFlow.js'
|
|
22
|
+
export async function findInstalledHfModelForInput(input: string): Promise<LocalHfModel | null> {
|
|
23
|
+
const ref = parseHuggingFaceRef(input)
|
|
24
|
+
const installed = await loadLocalHfModels()
|
|
25
|
+
return installed.find(model =>
|
|
26
|
+
model.status === 'ready'
|
|
27
|
+
&& model.repoId === ref.repoId
|
|
28
|
+
&& (!ref.filename || model.filename === ref.filename)
|
|
29
|
+
) ?? null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function chooseInstalledHfModelForRepo(
|
|
33
|
+
installed: LocalHfModel[],
|
|
34
|
+
repo: HuggingFaceRepoInfo,
|
|
35
|
+
files: HuggingFaceSibling[],
|
|
36
|
+
requestedFilename: string | undefined,
|
|
37
|
+
spec: SpecSnapshot | undefined,
|
|
38
|
+
): LocalHfModel | null {
|
|
39
|
+
const compatibleFiles = new Set(files.map(file => file.filename))
|
|
40
|
+
const candidates = installed.filter(model =>
|
|
41
|
+
model.status === 'ready'
|
|
42
|
+
&& model.repoId === repo.repoId
|
|
43
|
+
&& compatibleFiles.has(model.filename)
|
|
44
|
+
&& (!requestedFilename || model.filename === requestedFilename)
|
|
45
|
+
)
|
|
46
|
+
if (requestedFilename || candidates.length <= 1) return candidates[0] ?? null
|
|
47
|
+
|
|
48
|
+
const orderedFiles = spec
|
|
49
|
+
? orderGgufFilesForSpec(repo, files, spec).map(item => item.file.filename)
|
|
50
|
+
: files.map(file => file.filename)
|
|
51
|
+
for (const filename of orderedFiles) {
|
|
52
|
+
const match = candidates.find(model => model.filename === filename)
|
|
53
|
+
if (match) return match
|
|
54
|
+
}
|
|
55
|
+
return candidates[0] ?? null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function inspectHfInput(
|
|
59
|
+
state: Extract<State, { kind: 'hfInput' }>,
|
|
60
|
+
value: string,
|
|
61
|
+
setState: (s: State) => void,
|
|
62
|
+
): Promise<void> {
|
|
63
|
+
const input = value.trim()
|
|
64
|
+
if (!input) {
|
|
65
|
+
setState({ ...state, error: 'paste a model link or repo id' })
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
setState({ kind: 'hfLoading', data: state.data, input })
|
|
69
|
+
try {
|
|
70
|
+
const ref = parseHuggingFaceRef(input)
|
|
71
|
+
const repo = await fetchHuggingFaceRepoInfo(ref)
|
|
72
|
+
const files = ggufFiles(repo)
|
|
73
|
+
if (files.length === 0) {
|
|
74
|
+
setState({
|
|
75
|
+
kind: 'hfInput',
|
|
76
|
+
data: state.data,
|
|
77
|
+
error: 'no compatible local model files found; paste a different model link',
|
|
78
|
+
})
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
const installed = chooseInstalledHfModelForRepo(
|
|
82
|
+
await loadLocalHfModels(),
|
|
83
|
+
repo,
|
|
84
|
+
files,
|
|
85
|
+
ref.filename,
|
|
86
|
+
state.data.machineSpec,
|
|
87
|
+
)
|
|
88
|
+
if (installed) {
|
|
89
|
+
setState({
|
|
90
|
+
kind: 'hfDone',
|
|
91
|
+
data: { ...state.data, hfModels: await loadHfPickerModels() },
|
|
92
|
+
model: installed,
|
|
93
|
+
alreadyInstalled: true,
|
|
94
|
+
})
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
const recommendedFilename = state.data.machineSpec
|
|
98
|
+
? recommendGgufFile(repo, files, state.data.machineSpec)?.file.filename
|
|
99
|
+
: files[0]?.filename
|
|
100
|
+
if (ref.filename || files.length === 1) {
|
|
101
|
+
const plan = await createHfDownloadPlan(input, ref.filename ?? recommendedFilename)
|
|
102
|
+
setState({ kind: 'hfReview', data: state.data, plan })
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
setState({ kind: 'hfFilePick', data: state.data, input, repo, files })
|
|
106
|
+
} catch (err: unknown) {
|
|
107
|
+
setState({ kind: 'hfInput', data: state.data, error: (err as Error).message })
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function reviewHfFile(
|
|
112
|
+
state: Extract<State, { kind: 'hfFilePick' }>,
|
|
113
|
+
filename: string,
|
|
114
|
+
setState: (s: State) => void,
|
|
115
|
+
): Promise<void> {
|
|
116
|
+
setState({ kind: 'hfLoading', data: state.data, input: state.input })
|
|
117
|
+
try {
|
|
118
|
+
const installed = chooseInstalledHfModelForRepo(
|
|
119
|
+
await loadLocalHfModels(),
|
|
120
|
+
state.repo,
|
|
121
|
+
state.files,
|
|
122
|
+
filename,
|
|
123
|
+
state.data.machineSpec,
|
|
124
|
+
)
|
|
125
|
+
if (installed) {
|
|
126
|
+
setState({
|
|
127
|
+
kind: 'hfDone',
|
|
128
|
+
data: { ...state.data, hfModels: await loadHfPickerModels() },
|
|
129
|
+
model: installed,
|
|
130
|
+
alreadyInstalled: true,
|
|
131
|
+
})
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
const plan = await createHfDownloadPlan(state.input, filename)
|
|
135
|
+
setState({ kind: 'hfReview', data: state.data, plan })
|
|
136
|
+
} catch (err: unknown) {
|
|
137
|
+
setState({ kind: 'hfError', data: state.data, message: (err as Error).message, input: state.input })
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function startHfDownload(
|
|
142
|
+
state: Extract<State, { kind: 'hfReview' }>,
|
|
143
|
+
setState: (s: State) => void,
|
|
144
|
+
abortRef: React.MutableRefObject<AbortController | null>,
|
|
145
|
+
onPick: (sel: ModelPickerSelection) => void,
|
|
146
|
+
): Promise<void> {
|
|
147
|
+
const controller = new AbortController()
|
|
148
|
+
abortRef.current = controller
|
|
149
|
+
setState({ kind: 'hfDownloading', data: state.data, plan: state.plan, progress: { status: 'starting', completed: 0, total: state.plan.sizeBytes } })
|
|
150
|
+
try {
|
|
151
|
+
for await (const progress of downloadHfModel(state.plan, controller.signal)) {
|
|
152
|
+
if (controller.signal.aborted) return
|
|
153
|
+
setState({ kind: 'hfDownloading', data: state.data, plan: state.plan, progress })
|
|
154
|
+
}
|
|
155
|
+
const model = await findLocalHfModel(`${state.plan.repoId}#${state.plan.filename}`)
|
|
156
|
+
?? modelFromPlan(state.plan, undefined, 'ready')
|
|
157
|
+
const data = {
|
|
158
|
+
...state.data,
|
|
159
|
+
hfModels: await loadHfPickerModels(),
|
|
160
|
+
}
|
|
161
|
+
await startAndPickHfModel(model, { kind: 'hfDone', data, model }, setState, onPick)
|
|
162
|
+
} catch (err: unknown) {
|
|
163
|
+
if (controller.signal.aborted) return
|
|
164
|
+
setState({ kind: 'hfError', data: state.data, message: (err as Error).message, input: state.plan.repoId })
|
|
165
|
+
} finally {
|
|
166
|
+
abortRef.current = null
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function downloadMmprojAndContinue(
|
|
171
|
+
state: Extract<State, { kind: 'mmprojOffer' }>,
|
|
172
|
+
setState: (s: State) => void,
|
|
173
|
+
onPick: (sel: ModelPickerSelection) => void,
|
|
174
|
+
): Promise<void> {
|
|
175
|
+
setState({ kind: 'mmprojDownloading', data: state.data, model: state.model, progress: { status: 'starting' } })
|
|
176
|
+
try {
|
|
177
|
+
for await (const progress of addMmprojToInstalledModel(state.model.id)) {
|
|
178
|
+
setState({ kind: 'mmprojDownloading', data: state.data, model: state.model, progress })
|
|
179
|
+
}
|
|
180
|
+
} catch (err: unknown) {
|
|
181
|
+
setState({ kind: 'mmprojError', data: state.data, model: state.model, message: (err as Error).message })
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
const updated = await findLocalHfModel(state.model.id)
|
|
185
|
+
if (!updated || !updated.mmprojPath) {
|
|
186
|
+
setState({ kind: 'mmprojError', data: state.data, model: state.model, message: 'projector downloaded but path was not persisted' })
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
await stopLlamaCppServer().catch(() => null)
|
|
190
|
+
const data = { ...state.data, hfModels: await loadHfPickerModels() }
|
|
191
|
+
await startAndPickHfModel(updated, { kind: 'mmprojOffer', data, model: updated }, setState, onPick)
|
|
192
|
+
}
|