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.
Files changed (73) hide show
  1. package/README.md +6 -1
  2. package/package.json +3 -1
  3. package/src/app/FirstRun.tsx +1 -24
  4. package/src/app/firstRunConfig.ts +26 -0
  5. package/src/auth/openaiOAuth/landingPage.ts +2 -11
  6. package/src/chat/ChatScreen.tsx +32 -117
  7. package/src/chat/MessageList.tsx +18 -260
  8. package/src/chat/chatEnvironment.ts +16 -0
  9. package/src/chat/chatTurnContext.ts +50 -0
  10. package/src/chat/chatTurnOrchestrator.ts +5 -112
  11. package/src/chat/chatTurnRows.ts +64 -0
  12. package/src/chat/commands.ts +3 -178
  13. package/src/chat/continuityEditReview.ts +42 -0
  14. package/src/chat/input/ChatInput.tsx +10 -144
  15. package/src/chat/input/chatInputHelpers.ts +62 -0
  16. package/src/chat/input/inputRendering.tsx +93 -0
  17. package/src/chat/messageMarkdown.ts +220 -0
  18. package/src/chat/messageRows.ts +43 -0
  19. package/src/chat/planImplementation.ts +62 -0
  20. package/src/chat/slashCommandHandlers.ts +165 -0
  21. package/src/chat/slashCommandViews.ts +120 -0
  22. package/src/cli/main.tsx +7 -0
  23. package/src/identity/continuity/challenges.ts +123 -0
  24. package/src/identity/continuity/envelope.ts +49 -1484
  25. package/src/identity/continuity/envelopeCreate.ts +322 -0
  26. package/src/identity/continuity/envelopeCrypto.ts +182 -0
  27. package/src/identity/continuity/envelopeParse.ts +441 -0
  28. package/src/identity/continuity/envelopeTypes.ts +204 -0
  29. package/src/identity/continuity/envelopeVersion.ts +1 -0
  30. package/src/identity/continuity/payloadNormalization.ts +183 -0
  31. package/src/identity/continuity/publicSkills.ts +5 -5
  32. package/src/identity/continuity/skills/loadSkills.ts +12 -69
  33. package/src/identity/continuity/skills/skillPaths.ts +76 -0
  34. package/src/identity/continuity/skillsNormalization.ts +119 -0
  35. package/src/identity/continuity/snapshotToken.ts +28 -0
  36. package/src/identity/hub/continuity/completion.ts +67 -0
  37. package/src/identity/hub/continuity/effects.ts +5 -62
  38. package/src/identity/hub/profile/effects.ts +6 -170
  39. package/src/identity/hub/profile/operatorSave.ts +202 -0
  40. package/src/identity/registry/erc8004/metadata.ts +31 -23
  41. package/src/identity/wallet/browserWallet/html.ts +1 -57
  42. package/src/identity/wallet/browserWallet/walletPageSource.ts +85 -0
  43. package/src/identity/wallet/page/controller.ts +1 -1
  44. package/src/identity/wallet/page/errorView.ts +122 -0
  45. package/src/identity/wallet/page/view.ts +3 -114
  46. package/src/mcp/manager.ts +8 -66
  47. package/src/mcp/managerHelpers.ts +70 -0
  48. package/src/models/ModelPicker.tsx +69 -889
  49. package/src/models/huggingface.ts +20 -137
  50. package/src/models/huggingfaceStorage.ts +136 -0
  51. package/src/models/llamacpp.ts +37 -303
  52. package/src/models/llamacppCommands.ts +44 -0
  53. package/src/models/llamacppConfig.ts +34 -0
  54. package/src/models/llamacppDiscovery.ts +176 -0
  55. package/src/models/llamacppOutput.ts +65 -0
  56. package/src/models/modelPickerCatalogFlow.ts +56 -0
  57. package/src/models/modelPickerCredentials.ts +166 -0
  58. package/src/models/modelPickerData.ts +41 -0
  59. package/src/models/modelPickerDisplay.tsx +132 -0
  60. package/src/models/modelPickerHfFlow.ts +192 -0
  61. package/src/models/modelPickerLocalRunnerFlow.ts +115 -0
  62. package/src/models/modelPickerTypes.ts +69 -0
  63. package/src/models/modelPickerUninstallFlow.ts +48 -0
  64. package/src/models/modelPickerViewHelpers.ts +174 -0
  65. package/src/providers/openai-chat.ts +5 -124
  66. package/src/providers/openaiChatWire.ts +124 -0
  67. package/src/runtime/providerTurn.ts +38 -0
  68. package/src/runtime/textToolParser.ts +161 -0
  69. package/src/runtime/toolIntent.ts +1 -1
  70. package/src/runtime/turn.ts +43 -499
  71. package/src/runtime/turnNudges.ts +223 -0
  72. package/src/runtime/turnTypes.ts +86 -0
  73. 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
+ }