ethagent 0.2.0 → 1.0.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/LICENSE +21 -0
- package/README.md +114 -32
- package/bin/ethagent.js +11 -2
- package/package.json +30 -8
- 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 +868 -0
- package/src/identity/hub/identityHubEffects.ts +1146 -0
- package/src/identity/hub/identityHubModel.ts +291 -0
- package/src/identity/hub/identityHubReducer.ts +212 -0
- package/src/identity/hub/screens/BusyScreen.tsx +26 -0
- package/src/identity/hub/screens/ContinuityDashboardScreen.tsx +144 -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,150 @@
|
|
|
1
|
+
import {
|
|
2
|
+
startLlamaCppServer,
|
|
3
|
+
type LlamaCppStartFailureCode,
|
|
4
|
+
type LlamaCppStartResult,
|
|
5
|
+
} from './llamacpp.js'
|
|
6
|
+
import { findLocalHfModel, type LocalHfModel } from './huggingface.js'
|
|
7
|
+
import { localProviderBaseUrlFor, type EthagentConfig } from '../storage/config.js'
|
|
8
|
+
import { formatModelDisplayName } from './modelDisplay.js'
|
|
9
|
+
|
|
10
|
+
export type LlamaCppPreflightResult =
|
|
11
|
+
| { ok: true; alreadyRunning: boolean }
|
|
12
|
+
| {
|
|
13
|
+
ok: false
|
|
14
|
+
code: LlamaCppStartFailureCode
|
|
15
|
+
message: string
|
|
16
|
+
detail?: string
|
|
17
|
+
servedModels?: string[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type LlamaCppPreflightDeps = {
|
|
21
|
+
fetchImpl?: typeof fetch
|
|
22
|
+
findLocalModel?: typeof findLocalHfModel
|
|
23
|
+
startServer?: typeof startLlamaCppServer
|
|
24
|
+
timeoutMs?: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type ModelsProbe =
|
|
28
|
+
| { up: true; models: string[] }
|
|
29
|
+
| { up: false; models: [] }
|
|
30
|
+
|
|
31
|
+
export async function ensureLlamaCppRunnerReady(
|
|
32
|
+
config: EthagentConfig,
|
|
33
|
+
deps: LlamaCppPreflightDeps = {},
|
|
34
|
+
): Promise<LlamaCppPreflightResult> {
|
|
35
|
+
if (config.provider !== 'llamacpp') return { ok: true, alreadyRunning: true }
|
|
36
|
+
|
|
37
|
+
const baseUrl = localProviderBaseUrlFor('llamacpp', config.baseUrl)
|
|
38
|
+
const local = await (deps.findLocalModel ?? findLocalHfModel)(config.model)
|
|
39
|
+
if (!local || local.status !== 'ready') {
|
|
40
|
+
return {
|
|
41
|
+
ok: false,
|
|
42
|
+
code: 'model-file-missing',
|
|
43
|
+
message: formatPreflightFailure(
|
|
44
|
+
'local model is not imported',
|
|
45
|
+
config.model,
|
|
46
|
+
'choose an imported Hugging Face GGUF model from view full catalog or add a local model file',
|
|
47
|
+
),
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const probe = await probeLlamaCppModels(baseUrl, deps)
|
|
52
|
+
if (probe.up) {
|
|
53
|
+
if (probe.models.length === 0 || probe.models.includes(config.model)) {
|
|
54
|
+
return { ok: true, alreadyRunning: true }
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
ok: false,
|
|
58
|
+
code: 'different-model-running',
|
|
59
|
+
message: formatPreflightFailure(
|
|
60
|
+
'local runner is serving a different model',
|
|
61
|
+
config.model,
|
|
62
|
+
`a different local model is already running (${probe.models.join(', ')}); stop it before switching models`,
|
|
63
|
+
),
|
|
64
|
+
servedModels: probe.models,
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const result = await (deps.startServer ?? startLlamaCppServer)({
|
|
69
|
+
modelPath: local.localPath,
|
|
70
|
+
modelAlias: local.id,
|
|
71
|
+
host: llamaCppServerHostFromBaseUrl(baseUrl),
|
|
72
|
+
})
|
|
73
|
+
if (result.ok) return { ok: true, alreadyRunning: result.alreadyRunning }
|
|
74
|
+
return withPreflightMessage(result, local)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function probeLlamaCppModels(
|
|
78
|
+
baseUrl: string,
|
|
79
|
+
deps: Pick<LlamaCppPreflightDeps, 'fetchImpl' | 'timeoutMs'> = {},
|
|
80
|
+
): Promise<ModelsProbe> {
|
|
81
|
+
const controller = new AbortController()
|
|
82
|
+
const timer = setTimeout(() => controller.abort(), deps.timeoutMs ?? 800)
|
|
83
|
+
try {
|
|
84
|
+
const response = await (deps.fetchImpl ?? fetch)(llamaCppModelsEndpointForBaseUrl(baseUrl), {
|
|
85
|
+
signal: controller.signal,
|
|
86
|
+
})
|
|
87
|
+
if (!response.ok) return { up: false, models: [] }
|
|
88
|
+
const data = await response.json() as { data?: Array<{ id?: unknown }> }
|
|
89
|
+
return {
|
|
90
|
+
up: true,
|
|
91
|
+
models: (data.data ?? [])
|
|
92
|
+
.map(item => typeof item.id === 'string' ? item.id : '')
|
|
93
|
+
.filter(Boolean),
|
|
94
|
+
}
|
|
95
|
+
} catch {
|
|
96
|
+
return { up: false, models: [] }
|
|
97
|
+
} finally {
|
|
98
|
+
clearTimeout(timer)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function llamaCppModelsEndpointForBaseUrl(baseUrl: string): string {
|
|
103
|
+
const url = new URL(baseUrl)
|
|
104
|
+
const path = stripTrailingSlash(url.pathname)
|
|
105
|
+
url.pathname = path.endsWith('/v1') ? `${path}/models` : `${path}/v1/models`
|
|
106
|
+
url.search = ''
|
|
107
|
+
url.hash = ''
|
|
108
|
+
return url.toString()
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function llamaCppServerHostFromBaseUrl(baseUrl: string): string {
|
|
112
|
+
const url = new URL(baseUrl)
|
|
113
|
+
const path = stripTrailingSlash(url.pathname)
|
|
114
|
+
url.pathname = path.endsWith('/v1') ? stripTrailingSlash(path.slice(0, -3)) || '/' : path || '/'
|
|
115
|
+
url.search = ''
|
|
116
|
+
url.hash = ''
|
|
117
|
+
return stripTrailingSlash(url.toString())
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function withPreflightMessage(
|
|
121
|
+
result: Extract<LlamaCppStartResult, { ok: false }>,
|
|
122
|
+
local: LocalHfModel,
|
|
123
|
+
): Extract<LlamaCppPreflightResult, { ok: false }> {
|
|
124
|
+
return {
|
|
125
|
+
ok: false,
|
|
126
|
+
code: result.code,
|
|
127
|
+
message: formatPreflightFailure(
|
|
128
|
+
'local runner is not reachable',
|
|
129
|
+
local.id,
|
|
130
|
+
result.message,
|
|
131
|
+
local.displayName,
|
|
132
|
+
),
|
|
133
|
+
detail: result.detail,
|
|
134
|
+
servedModels: result.servedModels,
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function formatPreflightFailure(
|
|
139
|
+
prefix: string,
|
|
140
|
+
modelId: string,
|
|
141
|
+
reason: string,
|
|
142
|
+
displayName?: string,
|
|
143
|
+
): string {
|
|
144
|
+
const model = formatModelDisplayName('llamacpp', modelId, { displayName, maxLength: 64 })
|
|
145
|
+
return `${prefix}; failed to start ${model}: ${reason}`
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function stripTrailingSlash(value: string): string {
|
|
149
|
+
return value.replace(/\/+$/, '')
|
|
150
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
export type ModelDisplayProvider = string
|
|
2
|
+
|
|
3
|
+
type ModelDisplayOptions = {
|
|
4
|
+
maxLength?: number
|
|
5
|
+
displayName?: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const DEFAULT_MODEL_DISPLAY_MAX = 64
|
|
9
|
+
const HF_SEPARATOR = ' / '
|
|
10
|
+
|
|
11
|
+
export function formatModelDisplayName(
|
|
12
|
+
provider: ModelDisplayProvider,
|
|
13
|
+
model: string,
|
|
14
|
+
options: ModelDisplayOptions = {},
|
|
15
|
+
): string {
|
|
16
|
+
const maxLength = options.maxLength ?? DEFAULT_MODEL_DISPLAY_MAX
|
|
17
|
+
if (provider === 'llamacpp') {
|
|
18
|
+
return formatLocalHfModelDisplayName(model, {
|
|
19
|
+
maxLength,
|
|
20
|
+
displayName: options.displayName,
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
return truncateMiddle(model, maxLength)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function formatLocalHfModelDisplayName(
|
|
27
|
+
modelId: string,
|
|
28
|
+
options: ModelDisplayOptions = {},
|
|
29
|
+
): string {
|
|
30
|
+
const maxLength = options.maxLength ?? DEFAULT_MODEL_DISPLAY_MAX
|
|
31
|
+
const parsed = parseLocalHfModelId(modelId)
|
|
32
|
+
const label = options.displayName?.trim()
|
|
33
|
+
if (label) {
|
|
34
|
+
const parts = splitLocalHfDisplayName(label)
|
|
35
|
+
if (parts) return formatRepoAndFile(parts.repoId, parts.filename, maxLength)
|
|
36
|
+
if (parsed) return formatRepoAndFile(parsed.repoId, parsed.filename, maxLength)
|
|
37
|
+
return truncateMiddle(label, maxLength)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (parsed) return formatRepoAndFile(parsed.repoId, parsed.filename, maxLength)
|
|
41
|
+
return truncateMiddle(modelId, maxLength)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function truncateMiddle(value: string, maxLength: number): string {
|
|
45
|
+
if (maxLength <= 0) return ''
|
|
46
|
+
if (value.length <= maxLength) return value
|
|
47
|
+
if (maxLength <= 3) return value.slice(0, maxLength)
|
|
48
|
+
const remaining = maxLength - 3
|
|
49
|
+
const head = Math.ceil(remaining / 2)
|
|
50
|
+
const tail = Math.floor(remaining / 2)
|
|
51
|
+
return `${value.slice(0, head)}...${value.slice(value.length - tail)}`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseLocalHfModelId(modelId: string): { repoId: string; filename: string } | null {
|
|
55
|
+
const hash = modelId.indexOf('#')
|
|
56
|
+
if (hash <= 0 || hash === modelId.length - 1) return null
|
|
57
|
+
return {
|
|
58
|
+
repoId: modelId.slice(0, hash),
|
|
59
|
+
filename: modelId.slice(hash + 1),
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function splitLocalHfDisplayName(label: string): { repoId: string; filename: string } | null {
|
|
64
|
+
const separator = label.indexOf(HF_SEPARATOR)
|
|
65
|
+
if (separator <= 0 || separator === label.length - HF_SEPARATOR.length) return null
|
|
66
|
+
return {
|
|
67
|
+
repoId: label.slice(0, separator),
|
|
68
|
+
filename: label.slice(separator + HF_SEPARATOR.length),
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function formatRepoAndFile(repoId: string, filename: string, maxLength: number): string {
|
|
73
|
+
const file = friendlyFilename(filename)
|
|
74
|
+
const full = `${repoId}${HF_SEPARATOR}${file}`
|
|
75
|
+
if (full.length <= maxLength) return full
|
|
76
|
+
|
|
77
|
+
const separatorBudget = HF_SEPARATOR.length
|
|
78
|
+
const partBudget = maxLength - separatorBudget
|
|
79
|
+
if (partBudget <= 8) return truncateMiddle(full, maxLength)
|
|
80
|
+
|
|
81
|
+
let repoMax = Math.min(repoId.length, Math.max(8, Math.floor(partBudget * 0.45)))
|
|
82
|
+
let fileMax = partBudget - repoMax
|
|
83
|
+
|
|
84
|
+
if (fileMax > file.length) {
|
|
85
|
+
repoMax = Math.min(repoId.length, repoMax + fileMax - file.length)
|
|
86
|
+
fileMax = file.length
|
|
87
|
+
}
|
|
88
|
+
if (repoMax > repoId.length) {
|
|
89
|
+
fileMax = Math.min(file.length, fileMax + repoMax - repoId.length)
|
|
90
|
+
repoMax = repoId.length
|
|
91
|
+
}
|
|
92
|
+
if (fileMax < 8 && partBudget >= 16) {
|
|
93
|
+
fileMax = 8
|
|
94
|
+
repoMax = partBudget - fileMax
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return truncateMiddle(
|
|
98
|
+
`${truncateMiddle(repoId, repoMax)}${HF_SEPARATOR}${truncateMiddle(file, fileMax)}`,
|
|
99
|
+
maxLength,
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function friendlyFilename(filename: string): string {
|
|
104
|
+
return filename.split('/').pop() ?? filename
|
|
105
|
+
}
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
import { defaultModelFor, type ProviderId } from '../storage/config.js'
|
|
2
|
+
import { type ModelCatalogEntry, type ModelCatalogResult } from './catalog.js'
|
|
3
|
+
import type { HfRisk, HfTask } from './huggingface.js'
|
|
4
|
+
import type { SpecSnapshot } from './runtimeDetection.js'
|
|
5
|
+
import { contextWindowInfo } from '../runtime/compaction.js'
|
|
6
|
+
import { type SelectOption } from '../ui/Select.js'
|
|
7
|
+
import { formatLocalHfModelDisplayName, formatModelDisplayName } from './modelDisplay.js'
|
|
8
|
+
import { localModelId, quantizationFromFilename } from './huggingface.js'
|
|
9
|
+
import type { UncensoredCatalogEntry } from './uncensoredCatalog.js'
|
|
10
|
+
|
|
11
|
+
export type CloudProviderId = Exclude<ProviderId, 'llamacpp'>
|
|
12
|
+
|
|
13
|
+
export const MODEL_PICKER_CLOUD_PROVIDERS: CloudProviderId[] = ['openai', 'anthropic', 'gemini']
|
|
14
|
+
export const LOCAL_MODEL_LINK_HINT = 'paste a GGUF link'
|
|
15
|
+
export const LOCAL_MODEL_LINK_EXAMPLE = 'e.g. https://huggingface.co/Qwen/Qwen3-8B-GGUF'
|
|
16
|
+
|
|
17
|
+
export type LocalHfPickerModel = {
|
|
18
|
+
id: string
|
|
19
|
+
displayName: string
|
|
20
|
+
sizeBytes: number
|
|
21
|
+
quantization?: string
|
|
22
|
+
risk: HfRisk
|
|
23
|
+
task: HfTask
|
|
24
|
+
status: 'ready' | 'incomplete'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type ModelPickerOptionsData = {
|
|
28
|
+
llamaCpp: {
|
|
29
|
+
binaryPresent: boolean
|
|
30
|
+
serverUp: boolean
|
|
31
|
+
error?: string
|
|
32
|
+
}
|
|
33
|
+
hfModels: LocalHfPickerModel[]
|
|
34
|
+
machineSpec?: SpecSnapshot
|
|
35
|
+
cloudKeys: Partial<Record<ProviderId, boolean>>
|
|
36
|
+
cloudCatalogs: Partial<Record<ProviderId, ModelCatalogResult>>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type ModelPickerContextFit = {
|
|
40
|
+
usedTokens: number
|
|
41
|
+
thresholdPercent?: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type ModelPickerOptionsContext = {
|
|
45
|
+
currentProvider: ProviderId
|
|
46
|
+
currentModel: string
|
|
47
|
+
contextFit?: ModelPickerContextFit | null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const CURATED_CLOUD_MODEL_LIMIT = 3
|
|
51
|
+
const PROVIDER_INDENT = 2
|
|
52
|
+
const CHILD_INDENT = 4
|
|
53
|
+
|
|
54
|
+
export function buildModelPickerOptions(
|
|
55
|
+
data: ModelPickerOptionsData,
|
|
56
|
+
context: ModelPickerOptionsContext,
|
|
57
|
+
): SelectOption<string>[] {
|
|
58
|
+
const options: SelectOption<string>[] = []
|
|
59
|
+
|
|
60
|
+
options.push(sectionOption('hdr:local', 'local models'))
|
|
61
|
+
appendHfModelOptions(options, data, context, 'added from links', 46)
|
|
62
|
+
options.push(utilityOption('hf:download', 'add local model file', LOCAL_MODEL_LINK_HINT))
|
|
63
|
+
options.push(utilityOption('local:catalog', 'view full catalog', 'from configured hugging face repo'))
|
|
64
|
+
if (data.hfModels.length > 0) {
|
|
65
|
+
options.push(utilityOption('local:uninstall', 'uninstall downloaded GGUF'))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
options.push(sectionOption('hdr:cloud', 'cloud'))
|
|
69
|
+
for (const provider of MODEL_PICKER_CLOUD_PROVIDERS) {
|
|
70
|
+
options.push(groupOption(`hdr:cloud:${provider}`, provider))
|
|
71
|
+
const keySet = data.cloudKeys[provider] === true
|
|
72
|
+
if (!keySet) {
|
|
73
|
+
options.push(utilityOption(`key:set:${provider}`, 'api key · add'))
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const catalog = data.cloudCatalogs[provider]
|
|
78
|
+
if (catalog?.status === 'fallback') {
|
|
79
|
+
const reason = catalog.error ? ` · ${catalog.error}` : ''
|
|
80
|
+
options.push(noticeOption(
|
|
81
|
+
`hdr:cloud-fallback:${provider}`,
|
|
82
|
+
`catalog unavailable${reason} · showing configured model`,
|
|
83
|
+
CHILD_INDENT,
|
|
84
|
+
))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const models = orderModelsForContextFit(provider, cloudPickerModels(provider, catalog, context), context.contextFit)
|
|
88
|
+
if (models.length === 0) {
|
|
89
|
+
options.push(noticeOption(`hdr:cloud-empty:${provider}`, 'no selectable models', CHILD_INDENT))
|
|
90
|
+
}
|
|
91
|
+
for (const model of models) {
|
|
92
|
+
const active = context.currentProvider === provider && context.currentModel === model
|
|
93
|
+
const displayName = formatModelDisplayName(provider, model, { maxLength: 58 })
|
|
94
|
+
options.push(rowOption(
|
|
95
|
+
`c:${provider}:${model}`,
|
|
96
|
+
contextFitLabel(provider, model, `${displayName}${active ? ' *' : ''}`, context.contextFit),
|
|
97
|
+
))
|
|
98
|
+
}
|
|
99
|
+
options.push(utilityOption(`catalog:${provider}`, 'full catalog'))
|
|
100
|
+
options.push(utilityOption(`key:manage:${provider}`, 'api key · manage'))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return options
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function buildLocalModelCatalogOptions(
|
|
107
|
+
data: ModelPickerOptionsData,
|
|
108
|
+
context: ModelPickerOptionsContext,
|
|
109
|
+
catalog: UncensoredCatalogEntry[] = [],
|
|
110
|
+
): SelectOption<string>[] {
|
|
111
|
+
const options: SelectOption<string>[] = []
|
|
112
|
+
options.push(sectionOption('hdr:local-catalog', 'view full catalog'))
|
|
113
|
+
options.push(groupOption('hdr:uncensored:catalog', 'hugging face gguf files'))
|
|
114
|
+
if (catalog.length === 0) {
|
|
115
|
+
options.push(noticeOption('hdr:uncensored-empty', 'setup files unavailable; paste a GGUF link instead', CHILD_INDENT))
|
|
116
|
+
} else {
|
|
117
|
+
for (const entry of catalog) {
|
|
118
|
+
const id = localModelId(entry.repo.repoId, entry.file.filename)
|
|
119
|
+
const displayName = formatLocalHfModelDisplayName(id, {
|
|
120
|
+
displayName: entry.file.filename.split('/').pop() ?? entry.file.filename,
|
|
121
|
+
maxLength: 56,
|
|
122
|
+
})
|
|
123
|
+
const quant = quantLabel(entry.file.filename)
|
|
124
|
+
options.push(rowOption(
|
|
125
|
+
catalogOptionValue(entry.repo.repoId, entry.file.filename),
|
|
126
|
+
displayName,
|
|
127
|
+
undefined,
|
|
128
|
+
modelMetadataSubtext(`${quant} · ${formatSize(entry.file.sizeBytes ?? 0)}`, [
|
|
129
|
+
entry.recommended ? 'recommended for this machine' : '',
|
|
130
|
+
entry.installed ? 'installed' : '',
|
|
131
|
+
]),
|
|
132
|
+
))
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
appendHfModelOptions(options, data, context, 'downloaded GGUF', 50)
|
|
137
|
+
options.push(utilityOption('hf:download', 'add local model file', LOCAL_MODEL_LINK_HINT))
|
|
138
|
+
|
|
139
|
+
if (data.hfModels.length > 0) {
|
|
140
|
+
options.push(utilityOption('local:uninstall', 'uninstall downloaded GGUF'))
|
|
141
|
+
}
|
|
142
|
+
return options
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function appendHfModelOptions(
|
|
146
|
+
options: SelectOption<string>[],
|
|
147
|
+
data: ModelPickerOptionsData,
|
|
148
|
+
context: ModelPickerOptionsContext,
|
|
149
|
+
groupLabel: string,
|
|
150
|
+
maxLength: number,
|
|
151
|
+
): void {
|
|
152
|
+
options.push(groupOption('hdr:local:hf', groupLabel))
|
|
153
|
+
if (data.hfModels.length === 0) {
|
|
154
|
+
options.push(noticeOption('hdr:hf-empty', 'no downloaded files', CHILD_INDENT))
|
|
155
|
+
return
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const models = orderModelsForContextFit(
|
|
159
|
+
'llamacpp',
|
|
160
|
+
data.hfModels.map(model => model.id),
|
|
161
|
+
context.contextFit,
|
|
162
|
+
)
|
|
163
|
+
const byId = new Map(data.hfModels.map(model => [model.id, model]))
|
|
164
|
+
for (const id of models) {
|
|
165
|
+
const model = byId.get(id)
|
|
166
|
+
if (!model) continue
|
|
167
|
+
const active = context.currentProvider === 'llamacpp' && id === context.currentModel
|
|
168
|
+
const size = formatSize(model.sizeBytes)
|
|
169
|
+
const displayName = formatLocalHfModelDisplayName(id, {
|
|
170
|
+
displayName: model.displayName,
|
|
171
|
+
maxLength,
|
|
172
|
+
})
|
|
173
|
+
options.push(rowOption(
|
|
174
|
+
`hf:${id}`,
|
|
175
|
+
contextFitLabel('llamacpp', id, `${active ? '* ' : ' '}${displayName}`, context.contextFit),
|
|
176
|
+
undefined,
|
|
177
|
+
modelMetadataSubtext(size, ['installed']),
|
|
178
|
+
))
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function catalogOptionValue(repoId: string, filename: string): string {
|
|
183
|
+
return `uc:${repoId}#${filename}`
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function cloudPickerModels(
|
|
187
|
+
provider: CloudProviderId,
|
|
188
|
+
catalog: ModelCatalogResult | undefined,
|
|
189
|
+
_context: ModelPickerOptionsContext,
|
|
190
|
+
): string[] {
|
|
191
|
+
const entries = catalog?.entries ?? []
|
|
192
|
+
const discovered = catalog?.status === 'ok'
|
|
193
|
+
? curateDiscoveredCloudEntries(provider, entries).map(entry => entry.id)
|
|
194
|
+
: entries.map(entry => entry.id)
|
|
195
|
+
const models = dedupeStrings(discovered)
|
|
196
|
+
|
|
197
|
+
if (catalog?.status !== 'ok' && entries.length === 0) {
|
|
198
|
+
appendUnique(models, defaultModelFor(provider))
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return models
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function curateDiscoveredCloudEntries(
|
|
205
|
+
provider: CloudProviderId,
|
|
206
|
+
entries: ModelCatalogEntry[],
|
|
207
|
+
): ModelCatalogEntry[] {
|
|
208
|
+
const unique = dedupeEntries(entries)
|
|
209
|
+
const eligible = unique.filter(entry => isCuratedModelCandidate(provider, entry.id))
|
|
210
|
+
return rankEntriesByRecency(eligible).slice(0, CURATED_CLOUD_MODEL_LIMIT).map(item => item.entry)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function orderModelsForContextFit(
|
|
214
|
+
provider: ProviderId,
|
|
215
|
+
models: string[],
|
|
216
|
+
contextFit?: ModelPickerContextFit | null,
|
|
217
|
+
): string[] {
|
|
218
|
+
if (!contextFit) return models
|
|
219
|
+
return models
|
|
220
|
+
.map((model, index) => ({ model, index, fit: modelContextFit(provider, model, contextFit) }))
|
|
221
|
+
.sort((a, b) => {
|
|
222
|
+
if (a.fit.fits !== b.fit.fits) return a.fit.fits ? -1 : 1
|
|
223
|
+
return b.fit.windowTokens - a.fit.windowTokens || a.index - b.index
|
|
224
|
+
})
|
|
225
|
+
.map(item => item.model)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function contextFitLabel(
|
|
229
|
+
provider: ProviderId,
|
|
230
|
+
model: string,
|
|
231
|
+
baseLabel: string,
|
|
232
|
+
contextFit?: ModelPickerContextFit | null,
|
|
233
|
+
): string {
|
|
234
|
+
if (!contextFit) return baseLabel
|
|
235
|
+
const fit = modelContextFit(provider, model, contextFit)
|
|
236
|
+
return `${baseLabel} ${formatContextWindow(fit.windowTokens)} ctx ${fit.percent}%`
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function modelContextFit(provider: ProviderId, model: string, contextFit: ModelPickerContextFit): {
|
|
240
|
+
fits: boolean
|
|
241
|
+
percent: number
|
|
242
|
+
windowTokens: number
|
|
243
|
+
} {
|
|
244
|
+
const windowTokens = contextWindowInfo(provider, model).tokens
|
|
245
|
+
const percent = windowTokens > 0 ? Math.round((contextFit.usedTokens / windowTokens) * 100) : 0
|
|
246
|
+
const threshold = contextFit.thresholdPercent ?? 90
|
|
247
|
+
return { fits: percent < threshold, percent, windowTokens }
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function quantLabel(filename: string): string {
|
|
251
|
+
if (filename.toLowerCase().startsWith('mmproj-')) return 'Vision encoder'
|
|
252
|
+
return quantizationFromFilename(filename) ?? 'GGUF'
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function sectionOption(value: string, label: string): SelectOption<string> {
|
|
256
|
+
return {
|
|
257
|
+
value,
|
|
258
|
+
label,
|
|
259
|
+
disabled: true,
|
|
260
|
+
role: 'section',
|
|
261
|
+
bold: true,
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function groupOption(value: string, label: string): SelectOption<string> {
|
|
266
|
+
return {
|
|
267
|
+
value,
|
|
268
|
+
label,
|
|
269
|
+
disabled: true,
|
|
270
|
+
role: 'group',
|
|
271
|
+
bold: true,
|
|
272
|
+
indent: PROVIDER_INDENT,
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function noticeOption(value: string, label: string, indent = 0): SelectOption<string> {
|
|
277
|
+
return {
|
|
278
|
+
value,
|
|
279
|
+
label,
|
|
280
|
+
disabled: true,
|
|
281
|
+
role: 'notice',
|
|
282
|
+
prefix: 'note',
|
|
283
|
+
indent,
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function rowOption(value: string, label: string, hint?: string, subtext?: string): SelectOption<string> {
|
|
288
|
+
return {
|
|
289
|
+
value,
|
|
290
|
+
label,
|
|
291
|
+
subtext,
|
|
292
|
+
hint,
|
|
293
|
+
role: 'option',
|
|
294
|
+
indent: CHILD_INDENT,
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function utilityOption(value: string, label: string, hint?: string): SelectOption<string> {
|
|
299
|
+
return {
|
|
300
|
+
value,
|
|
301
|
+
label,
|
|
302
|
+
hint,
|
|
303
|
+
role: 'utility',
|
|
304
|
+
indent: CHILD_INDENT,
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function formatSize(bytes: number): string {
|
|
309
|
+
if (bytes <= 0) return ''
|
|
310
|
+
const gb = bytes / 1e9
|
|
311
|
+
if (gb >= 1) return `${gb.toFixed(1)} GB`
|
|
312
|
+
return `${Math.round(bytes / 1e6)} MB`
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function modelMetadataSubtext(size: string, indicators: string[]): string | undefined {
|
|
316
|
+
return [size, ...indicators].filter(Boolean).join(' · ') || undefined
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function formatContextWindow(tokens: number): string {
|
|
320
|
+
if (tokens >= 1_000_000) {
|
|
321
|
+
const millions = tokens / 1_000_000
|
|
322
|
+
return Number.isInteger(millions) ? `${millions}m` : `${millions.toFixed(1)}m`
|
|
323
|
+
}
|
|
324
|
+
if (tokens >= 1000) return `${Math.round(tokens / 1000)}k`
|
|
325
|
+
return String(tokens)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
type RankedEntry = {
|
|
329
|
+
entry: ModelCatalogEntry
|
|
330
|
+
score: number
|
|
331
|
+
index: number
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function rankEntriesByRecency(entries: ModelCatalogEntry[]): RankedEntry[] {
|
|
335
|
+
return entries
|
|
336
|
+
.map((entry, index) => ({
|
|
337
|
+
entry,
|
|
338
|
+
index,
|
|
339
|
+
score: recencyScore(entry.id),
|
|
340
|
+
}))
|
|
341
|
+
.sort((a, b) => b.score - a.score || a.index - b.index)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function recencyScore(id: string): number {
|
|
345
|
+
return dateScore(id) || versionScore(id)
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function dateScore(id: string): number {
|
|
349
|
+
const iso = id.match(/(?:^|[-_])(\d{4})[-_](\d{2})[-_](\d{2})(?=$|[-_])/)
|
|
350
|
+
if (iso) return Number(`${iso[1]}${iso[2]}${iso[3]}`)
|
|
351
|
+
const compact = id.match(/(?:^|[-_])(\d{8})(?=$|[-_])/)
|
|
352
|
+
if (compact) return Number(compact[1])
|
|
353
|
+
const monthYear = id.match(/(?:^|[-_])(\d{1,2})[-_](\d{4})(?=$|[-_])/)
|
|
354
|
+
if (monthYear) return Number(`${monthYear[2]}${monthYear[1]?.padStart(2, '0')}00`)
|
|
355
|
+
return 0
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function versionScore(id: string): number {
|
|
359
|
+
const match = id.match(/(?:^|[-_])(\d+(?:[.-]\d+){0,3})(?=$|[-_])/)
|
|
360
|
+
if (!match) return 0
|
|
361
|
+
const version = match[1]
|
|
362
|
+
if (!version) return 0
|
|
363
|
+
return version
|
|
364
|
+
.split(/[.-]/)
|
|
365
|
+
.slice(0, 4)
|
|
366
|
+
.reduce((score, part, index) => {
|
|
367
|
+
const value = Number.parseInt(part, 10)
|
|
368
|
+
if (!Number.isFinite(value)) return score
|
|
369
|
+
return score + value * Math.pow(100, 3 - index)
|
|
370
|
+
}, 0)
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const CURATED_EXCLUDED_TOKENS = [
|
|
374
|
+
'alpha',
|
|
375
|
+
'beta',
|
|
376
|
+
'deep-research',
|
|
377
|
+
'dev',
|
|
378
|
+
'experimental',
|
|
379
|
+
'preview',
|
|
380
|
+
'test',
|
|
381
|
+
] as const
|
|
382
|
+
|
|
383
|
+
function isCuratedModelCandidate(provider: CloudProviderId, id: string): boolean {
|
|
384
|
+
const lower = id.toLowerCase()
|
|
385
|
+
if (provider === 'gemini' && !lower.startsWith('gemini-')) return false
|
|
386
|
+
return !CURATED_EXCLUDED_TOKENS.some(token => hasToken(lower, token))
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function hasToken(id: string, token: string): boolean {
|
|
390
|
+
return new RegExp(`(^|[-_.])${escapeRegExp(token)}($|[-_.])`).test(id)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function escapeRegExp(value: string): string {
|
|
394
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function dedupeEntries(entries: ModelCatalogEntry[]): ModelCatalogEntry[] {
|
|
398
|
+
const seen = new Set<string>()
|
|
399
|
+
const out: ModelCatalogEntry[] = []
|
|
400
|
+
for (const entry of entries) {
|
|
401
|
+
if (seen.has(entry.id)) continue
|
|
402
|
+
seen.add(entry.id)
|
|
403
|
+
out.push(entry)
|
|
404
|
+
}
|
|
405
|
+
return out
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function dedupeStrings(values: string[]): string[] {
|
|
409
|
+
const seen = new Set<string>()
|
|
410
|
+
const out: string[] = []
|
|
411
|
+
for (const value of values) {
|
|
412
|
+
if (seen.has(value)) continue
|
|
413
|
+
seen.add(value)
|
|
414
|
+
out.push(value)
|
|
415
|
+
}
|
|
416
|
+
return out
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function appendUnique(values: string[], value: string): void {
|
|
420
|
+
if (!values.includes(value)) values.push(value)
|
|
421
|
+
}
|