ethagent 0.2.1 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +114 -32
- package/bin/ethagent.js +11 -2
- package/package.json +25 -7
- package/src/app/FirstRun.tsx +412 -0
- package/src/app/hooks/useCancelRequest.ts +22 -0
- package/src/app/hooks/useDoublePress.ts +46 -0
- package/src/app/hooks/useExitOnCtrlC.ts +36 -0
- package/src/app/input/AppInputProvider.tsx +116 -0
- package/src/app/input/appInputParser.ts +279 -0
- package/src/app/keybindings/KeybindingProvider.tsx +134 -0
- package/src/app/keybindings/resolver.ts +42 -0
- package/src/app/keybindings/types.ts +26 -0
- package/src/chat/ChatBottomPane.tsx +280 -0
- package/src/chat/ChatInput.tsx +722 -0
- package/src/chat/ChatScreen.tsx +1575 -0
- package/src/chat/ContextLimitView.tsx +95 -0
- package/src/chat/ContinuityEditReviewView.tsx +48 -0
- package/src/chat/ConversationStack.tsx +47 -0
- package/src/chat/CopyPicker.tsx +52 -0
- package/src/chat/MessageList.tsx +609 -0
- package/src/chat/PermissionPrompt.tsx +153 -0
- package/src/chat/PermissionsView.tsx +159 -0
- package/src/chat/PlanApprovalView.tsx +91 -0
- package/src/chat/ResumeView.tsx +267 -0
- package/src/chat/RewindView.tsx +386 -0
- package/src/chat/SessionStatus.tsx +51 -0
- package/src/chat/TranscriptView.tsx +202 -0
- package/src/chat/chatInputState.ts +247 -0
- package/src/chat/chatPaste.ts +49 -0
- package/src/chat/chatScreenUtils.ts +187 -0
- package/src/chat/chatSessionState.ts +142 -0
- package/src/chat/chatTurnOrchestrator.ts +701 -0
- package/src/chat/commands.ts +673 -0
- package/src/chat/textCursor.ts +202 -0
- package/src/chat/toolResultDisplay.ts +8 -0
- package/src/chat/transcriptViewport.ts +247 -0
- package/src/cli/ResetConfirmView.tsx +61 -0
- package/src/cli/main.tsx +177 -0
- package/src/cli/preview.tsx +19 -0
- package/src/cli/reset.ts +106 -0
- package/src/identity/continuity/editor.ts +149 -0
- package/src/identity/continuity/envelope.ts +345 -0
- package/src/identity/continuity/history.ts +153 -0
- package/src/identity/continuity/privateEdit.ts +334 -0
- package/src/identity/continuity/publicSkills.ts +173 -0
- package/src/identity/continuity/snapshots.ts +183 -0
- package/src/identity/continuity/storage.ts +507 -0
- package/src/identity/crypto/backupEnvelope.ts +486 -0
- package/src/identity/crypto/eth.ts +137 -0
- package/src/identity/hub/IdentityHub.tsx +845 -0
- package/src/identity/hub/identityHubEffects.ts +1100 -0
- package/src/identity/hub/identityHubModel.ts +291 -0
- package/src/identity/hub/identityHubReducer.ts +209 -0
- package/src/identity/hub/screens/BusyScreen.tsx +26 -0
- package/src/identity/hub/screens/ContinuityDashboardScreen.tsx +139 -0
- package/src/identity/hub/screens/CreateFlow.tsx +206 -0
- package/src/identity/hub/screens/DetailsScreen.tsx +64 -0
- package/src/identity/hub/screens/EditProfileFlow.tsx +145 -0
- package/src/identity/hub/screens/ErrorScreen.tsx +35 -0
- package/src/identity/hub/screens/IdentitySummary.tsx +70 -0
- package/src/identity/hub/screens/MenuScreen.tsx +117 -0
- package/src/identity/hub/screens/NetworkScreen.tsx +41 -0
- package/src/identity/hub/screens/RebackupStorageScreen.tsx +50 -0
- package/src/identity/hub/screens/RecoveryConfirmScreen.tsx +85 -0
- package/src/identity/hub/screens/RestoreFlow.tsx +206 -0
- package/src/identity/hub/screens/StorageCredentialScreen.tsx +128 -0
- package/src/identity/hub/screens/WalletApprovalScreen.tsx +43 -0
- package/src/identity/profile/imagePicker.ts +180 -0
- package/src/identity/registry/erc8004.ts +1106 -0
- package/src/identity/registry/registryConfig.ts +69 -0
- package/src/identity/storage/ipfs.ts +212 -0
- package/src/identity/storage/pinataJwt.ts +53 -0
- package/src/identity/wallet/browserWallet.ts +393 -0
- package/src/identity/wallet/wallet-page/wallet.html +1082 -0
- package/src/mcp/approvals.ts +113 -0
- package/src/mcp/config.ts +235 -0
- package/src/mcp/manager.ts +541 -0
- package/src/mcp/names.ts +19 -0
- package/src/mcp/output.ts +96 -0
- package/src/models/ModelPicker.tsx +1446 -0
- package/src/models/catalog.ts +296 -0
- package/src/models/huggingface.ts +651 -0
- package/src/models/llamacpp.ts +810 -0
- package/src/models/llamacppPreflight.ts +150 -0
- package/src/models/modelDisplay.ts +105 -0
- package/src/models/modelPickerOptions.ts +421 -0
- package/src/models/modelRecommendation.ts +140 -0
- package/src/models/runtimeDetection.ts +81 -0
- package/src/models/uncensoredCatalog.ts +86 -0
- package/src/providers/anthropic.ts +259 -0
- package/src/providers/contracts.ts +62 -0
- package/src/providers/errors.ts +62 -0
- package/src/providers/gemini.ts +152 -0
- package/src/providers/openai-chat.ts +472 -0
- package/src/providers/registry.ts +42 -0
- package/src/providers/retry.ts +58 -0
- package/src/providers/sse.ts +93 -0
- package/src/runtime/compaction.ts +389 -0
- package/src/runtime/cwd.ts +43 -0
- package/src/runtime/sessionMode.ts +55 -0
- package/src/runtime/systemPrompt.ts +209 -0
- package/src/runtime/toolClaimGuards.ts +143 -0
- package/src/runtime/toolExecution.ts +304 -0
- package/src/runtime/toolIntent.ts +163 -0
- package/src/runtime/turn.ts +858 -0
- package/src/storage/atomicWrite.ts +68 -0
- package/src/storage/config.ts +189 -0
- package/src/storage/factoryReset.ts +130 -0
- package/src/storage/history.ts +58 -0
- package/src/storage/identity.ts +99 -0
- package/src/storage/permissions.ts +76 -0
- package/src/storage/rewind.ts +246 -0
- package/src/storage/secrets.ts +181 -0
- package/src/storage/sessionExport.ts +49 -0
- package/src/storage/sessions.ts +482 -0
- package/src/tools/bashSafety.ts +174 -0
- package/src/tools/bashTool.ts +140 -0
- package/src/tools/changeDirectoryTool.ts +213 -0
- package/src/tools/contracts.ts +179 -0
- package/src/tools/deleteFileTool.ts +111 -0
- package/src/tools/editTool.ts +160 -0
- package/src/tools/editUtils.ts +170 -0
- package/src/tools/listDirectoryTool.ts +55 -0
- package/src/tools/mcpResourceTools.ts +95 -0
- package/src/tools/permissionRules.ts +85 -0
- package/src/tools/privateContinuityEditTool.ts +178 -0
- package/src/tools/privateContinuityReadTool.ts +107 -0
- package/src/tools/readTool.ts +85 -0
- package/src/tools/registry.ts +67 -0
- package/src/tools/writeFileTool.ts +142 -0
- package/src/ui/BrandSplash.tsx +193 -0
- package/src/ui/ProgressBar.tsx +34 -0
- package/src/ui/Select.tsx +143 -0
- package/src/ui/Spinner.tsx +269 -0
- package/src/ui/Surface.tsx +47 -0
- package/src/ui/TextInput.tsx +97 -0
- package/src/ui/theme.ts +59 -0
- package/src/utils/clipboard.ts +216 -0
- package/src/utils/markdownSegments.ts +51 -0
- package/src/utils/messages.ts +35 -0
- package/src/utils/withRetry.ts +280 -0
- package/src/cli.tsx +0 -147
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { createHash } from 'node:crypto'
|
|
4
|
+
import { getConfigDir, ensureConfigDir } from '../storage/config.js'
|
|
5
|
+
import { atomicWriteText } from '../storage/atomicWrite.js'
|
|
6
|
+
|
|
7
|
+
export type HfFileFormat = 'gguf' | 'safetensors' | 'pickle/bin' | 'unknown'
|
|
8
|
+
export type HfRuntime = 'llama.cpp runnable' | 'download-only' | 'unsupported'
|
|
9
|
+
export type HfTask = 'chat/instruct' | 'base' | 'code' | 'embedding' | 'vision' | 'unknown'
|
|
10
|
+
export type HfSizeClass = 'tiny' | 'small' | 'medium' | 'large'
|
|
11
|
+
export type HfRisk = 'low' | 'medium' | 'high'
|
|
12
|
+
export type HfCredibility = 'established' | 'normal' | 'low-signal'
|
|
13
|
+
export type LocalHfStatus = 'ready' | 'incomplete'
|
|
14
|
+
|
|
15
|
+
export type HuggingFaceRef = {
|
|
16
|
+
repoId: string
|
|
17
|
+
revision?: string
|
|
18
|
+
filename?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type HuggingFaceSibling = {
|
|
22
|
+
filename: string
|
|
23
|
+
sizeBytes?: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type HuggingFaceRepoInfo = {
|
|
27
|
+
repoId: string
|
|
28
|
+
author?: string
|
|
29
|
+
sha?: string
|
|
30
|
+
license?: string
|
|
31
|
+
downloads?: number
|
|
32
|
+
likes?: number
|
|
33
|
+
lastModified?: string
|
|
34
|
+
tags: string[]
|
|
35
|
+
siblings: HuggingFaceSibling[]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type HuggingFaceModelSearchItem = {
|
|
39
|
+
repoId: string
|
|
40
|
+
downloads?: number
|
|
41
|
+
likes?: number
|
|
42
|
+
lastModified?: string
|
|
43
|
+
tags: string[]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type HfSafetyReview = {
|
|
47
|
+
risk: HfRisk
|
|
48
|
+
credibility: HfCredibility
|
|
49
|
+
format: HfFileFormat
|
|
50
|
+
runtime: HfRuntime
|
|
51
|
+
task: HfTask
|
|
52
|
+
sizeClass: HfSizeClass
|
|
53
|
+
quantization?: string
|
|
54
|
+
reasons: string[]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type HfDownloadPlan = {
|
|
58
|
+
repo: HuggingFaceRepoInfo
|
|
59
|
+
repoId: string
|
|
60
|
+
requestedRevision: string
|
|
61
|
+
resolvedRevision: string
|
|
62
|
+
filename: string
|
|
63
|
+
sizeBytes: number
|
|
64
|
+
localPath: string
|
|
65
|
+
displayName: string
|
|
66
|
+
review: HfSafetyReview
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type LocalHfModel = {
|
|
70
|
+
id: string
|
|
71
|
+
provider: 'llamacpp'
|
|
72
|
+
repoId: string
|
|
73
|
+
requestedRevision: string
|
|
74
|
+
resolvedRevision: string
|
|
75
|
+
filename: string
|
|
76
|
+
displayName: string
|
|
77
|
+
localPath: string
|
|
78
|
+
sizeBytes: number
|
|
79
|
+
format: HfFileFormat
|
|
80
|
+
runtime: HfRuntime
|
|
81
|
+
task: HfTask
|
|
82
|
+
sizeClass: HfSizeClass
|
|
83
|
+
quantization?: string
|
|
84
|
+
risk: HfRisk
|
|
85
|
+
credibility: HfCredibility
|
|
86
|
+
license?: string
|
|
87
|
+
downloads?: number
|
|
88
|
+
likes?: number
|
|
89
|
+
reviewedAt: string
|
|
90
|
+
installedAt: string
|
|
91
|
+
status: LocalHfStatus
|
|
92
|
+
sha256?: string
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export type HfDownloadProgress = {
|
|
96
|
+
status: string
|
|
97
|
+
completed?: number
|
|
98
|
+
total?: number
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
type FetchImpl = typeof fetch
|
|
102
|
+
type UninstallDeps = {
|
|
103
|
+
unlink?: (target: string) => Promise<void>
|
|
104
|
+
rmdir?: (target: string) => Promise<void>
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
type ModelInfoResponse = {
|
|
108
|
+
id?: unknown
|
|
109
|
+
author?: unknown
|
|
110
|
+
sha?: unknown
|
|
111
|
+
downloads?: unknown
|
|
112
|
+
likes?: unknown
|
|
113
|
+
lastModified?: unknown
|
|
114
|
+
tags?: unknown
|
|
115
|
+
cardData?: unknown
|
|
116
|
+
siblings?: unknown
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
type ModelSearchResponseItem = {
|
|
120
|
+
id?: unknown
|
|
121
|
+
modelId?: unknown
|
|
122
|
+
downloads?: unknown
|
|
123
|
+
likes?: unknown
|
|
124
|
+
lastModified?: unknown
|
|
125
|
+
tags?: unknown
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const HF_BASE_URL = 'https://huggingface.co'
|
|
129
|
+
const DEFAULT_REVISION = 'main'
|
|
130
|
+
const COMMIT_RE = /^[a-f0-9]{40}$/i
|
|
131
|
+
const DOWNLOAD_PROGRESS_MIN_MS = 100
|
|
132
|
+
const DOWNLOAD_PROGRESS_MIN_BYTES = 16 * 1024 * 1024
|
|
133
|
+
|
|
134
|
+
export function getLocalHfModelsPath(): string {
|
|
135
|
+
return path.join(getConfigDir(), 'local-models.json')
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function getLocalHfCacheDir(): string {
|
|
139
|
+
return path.join(getConfigDir(), 'models', 'huggingface')
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function loadLocalHfModels(): Promise<LocalHfModel[]> {
|
|
143
|
+
try {
|
|
144
|
+
const raw = await fs.readFile(getLocalHfModelsPath(), 'utf8')
|
|
145
|
+
const parsed = JSON.parse(raw) as unknown
|
|
146
|
+
if (!Array.isArray(parsed)) return []
|
|
147
|
+
return parsed.filter(isLocalHfModel)
|
|
148
|
+
} catch (err: unknown) {
|
|
149
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []
|
|
150
|
+
return []
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function saveLocalHfModels(models: LocalHfModel[]): Promise<void> {
|
|
155
|
+
await ensureConfigDir()
|
|
156
|
+
await atomicWriteText(getLocalHfModelsPath(), JSON.stringify(models, null, 2) + '\n')
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function upsertLocalHfModel(model: LocalHfModel): Promise<void> {
|
|
160
|
+
const current = await loadLocalHfModels()
|
|
161
|
+
const next = [
|
|
162
|
+
model,
|
|
163
|
+
...current.filter(existing => existing.id !== model.id),
|
|
164
|
+
]
|
|
165
|
+
await saveLocalHfModels(next)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function findLocalHfModel(id: string): Promise<LocalHfModel | null> {
|
|
169
|
+
const models = await loadLocalHfModels()
|
|
170
|
+
return models.find(model => model.id === id) ?? null
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function uninstallLocalHfModel(
|
|
174
|
+
id: string,
|
|
175
|
+
deps: UninstallDeps = {},
|
|
176
|
+
): Promise<LocalHfModel | null> {
|
|
177
|
+
const models = await loadLocalHfModels()
|
|
178
|
+
const model = models.find(item => item.id === id)
|
|
179
|
+
if (!model) return null
|
|
180
|
+
|
|
181
|
+
const cacheRoot = path.resolve(getLocalHfCacheDir())
|
|
182
|
+
const modelPath = path.resolve(model.localPath)
|
|
183
|
+
const partialPath = path.resolve(`${model.localPath}.partial`)
|
|
184
|
+
if (!isPathInside(cacheRoot, modelPath) || !isPathInside(cacheRoot, partialPath)) {
|
|
185
|
+
throw new Error('refusing to uninstall a local model outside EthAgent model cache')
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const unlink = deps.unlink ?? ((target: string) => fs.unlink(target))
|
|
189
|
+
const rmdir = deps.rmdir ?? ((target: string) => fs.rmdir(target))
|
|
190
|
+
await unlinkIfPresent(modelPath, unlink)
|
|
191
|
+
await unlinkIfPresent(partialPath, unlink)
|
|
192
|
+
await cleanupEmptyParents(path.dirname(modelPath), cacheRoot, rmdir)
|
|
193
|
+
|
|
194
|
+
await saveLocalHfModels(models.filter(item => item.id !== id))
|
|
195
|
+
return model
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function parseHuggingFaceRef(input: string): HuggingFaceRef {
|
|
199
|
+
const trimmed = input.trim()
|
|
200
|
+
if (!trimmed) throw new Error('Hugging Face model link or repo id is required')
|
|
201
|
+
|
|
202
|
+
if (/^https?:\/\//i.test(trimmed)) {
|
|
203
|
+
const url = new URL(trimmed)
|
|
204
|
+
const host = url.hostname.toLowerCase()
|
|
205
|
+
if (host !== 'huggingface.co' && host !== 'www.huggingface.co') {
|
|
206
|
+
throw new Error('expected a huggingface.co model link')
|
|
207
|
+
}
|
|
208
|
+
const parts = url.pathname.split('/').filter(Boolean)
|
|
209
|
+
if (parts.length < 2) throw new Error('expected a Hugging Face repo link')
|
|
210
|
+
const repoId = `${decodeURIComponent(parts[0]!)}/${decodeURIComponent(parts[1]!)}`
|
|
211
|
+
const mode = parts[2]
|
|
212
|
+
if (mode === 'blob' || mode === 'resolve' || mode === 'tree') {
|
|
213
|
+
const revision = parts[3] ? decodeURIComponent(parts[3]) : undefined
|
|
214
|
+
const filename = parts.length > 4
|
|
215
|
+
? parts.slice(4).map(part => decodeURIComponent(part)).join('/')
|
|
216
|
+
: undefined
|
|
217
|
+
return { repoId, revision, filename }
|
|
218
|
+
}
|
|
219
|
+
return { repoId }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const withoutPrefix = trimmed.replace(/^hf:\/\//i, '')
|
|
223
|
+
const parts = withoutPrefix.split('/').filter(Boolean)
|
|
224
|
+
if (parts.length < 2) throw new Error('expected repo id like org/model or a huggingface.co link')
|
|
225
|
+
const repoId = `${parts[0]!}/${parts[1]!}`
|
|
226
|
+
let fileParts = parts.slice(2)
|
|
227
|
+
const mode = fileParts[0]
|
|
228
|
+
if ((mode === 'blob' || mode === 'resolve' || mode === 'tree') && fileParts.length >= 2) {
|
|
229
|
+
fileParts = fileParts.slice(2)
|
|
230
|
+
}
|
|
231
|
+
const filename = fileParts.length > 0 ? fileParts.join('/') : undefined
|
|
232
|
+
return { repoId, filename }
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export async function fetchHuggingFaceRepoInfo(
|
|
236
|
+
ref: HuggingFaceRef,
|
|
237
|
+
fetchImpl: FetchImpl = fetch,
|
|
238
|
+
): Promise<HuggingFaceRepoInfo> {
|
|
239
|
+
const url = new URL(`${HF_BASE_URL}/api/models/${encodeRepoPath(ref.repoId)}`)
|
|
240
|
+
url.searchParams.set('blobs', 'true')
|
|
241
|
+
if (ref.revision) url.searchParams.set('revision', ref.revision)
|
|
242
|
+
const response = await fetchImpl(url, { headers: { Accept: 'application/json' } })
|
|
243
|
+
if (!response.ok) {
|
|
244
|
+
if (response.status === 401 || response.status === 403) {
|
|
245
|
+
throw new Error('repo is gated or private')
|
|
246
|
+
}
|
|
247
|
+
if (response.status === 404) throw new Error('Hugging Face repo not found')
|
|
248
|
+
throw new Error(`Hugging Face API HTTP ${response.status}`)
|
|
249
|
+
}
|
|
250
|
+
const data = await response.json() as ModelInfoResponse
|
|
251
|
+
const tags = Array.isArray(data.tags)
|
|
252
|
+
? data.tags.filter((tag): tag is string => typeof tag === 'string')
|
|
253
|
+
: []
|
|
254
|
+
const siblings = Array.isArray(data.siblings)
|
|
255
|
+
? data.siblings.flatMap(sibling => parseSibling(sibling))
|
|
256
|
+
: []
|
|
257
|
+
return {
|
|
258
|
+
repoId: typeof data.id === 'string' ? data.id : ref.repoId,
|
|
259
|
+
author: typeof data.author === 'string' ? data.author : undefined,
|
|
260
|
+
sha: typeof data.sha === 'string' ? data.sha : undefined,
|
|
261
|
+
license: licenseFrom(data.cardData, tags),
|
|
262
|
+
downloads: typeof data.downloads === 'number' ? data.downloads : undefined,
|
|
263
|
+
likes: typeof data.likes === 'number' ? data.likes : undefined,
|
|
264
|
+
lastModified: typeof data.lastModified === 'string' ? data.lastModified : undefined,
|
|
265
|
+
tags,
|
|
266
|
+
siblings,
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export async function searchHuggingFaceModels(
|
|
271
|
+
query: string,
|
|
272
|
+
options: { limit?: number; filter?: string } = {},
|
|
273
|
+
fetchImpl: FetchImpl = fetch,
|
|
274
|
+
): Promise<HuggingFaceModelSearchItem[]> {
|
|
275
|
+
const url = new URL(`${HF_BASE_URL}/api/models`)
|
|
276
|
+
url.searchParams.set('search', query)
|
|
277
|
+
url.searchParams.set('sort', 'downloads')
|
|
278
|
+
url.searchParams.set('direction', '-1')
|
|
279
|
+
url.searchParams.set('limit', String(options.limit ?? 20))
|
|
280
|
+
if (options.filter) url.searchParams.set('filter', options.filter)
|
|
281
|
+
const response = await fetchImpl(url, { headers: { Accept: 'application/json' } })
|
|
282
|
+
if (!response.ok) throw new Error(`Hugging Face catalog HTTP ${response.status}`)
|
|
283
|
+
const data = await response.json() as unknown
|
|
284
|
+
if (!Array.isArray(data)) return []
|
|
285
|
+
return data.flatMap(parseSearchItem)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function ggufFiles(repo: HuggingFaceRepoInfo): HuggingFaceSibling[] {
|
|
289
|
+
return repo.siblings
|
|
290
|
+
.filter(file => file.filename.toLowerCase().endsWith('.gguf'))
|
|
291
|
+
.sort((a, b) => a.filename.localeCompare(b.filename))
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export async function createHfDownloadPlan(
|
|
295
|
+
input: string,
|
|
296
|
+
filename?: string,
|
|
297
|
+
deps: { fetchImpl?: FetchImpl; now?: () => Date } = {},
|
|
298
|
+
): Promise<HfDownloadPlan> {
|
|
299
|
+
const ref = parseHuggingFaceRef(input)
|
|
300
|
+
const selectedFilename = filename?.trim() || ref.filename
|
|
301
|
+
const repo = await fetchHuggingFaceRepoInfo(ref, deps.fetchImpl)
|
|
302
|
+
const files = ggufFiles(repo)
|
|
303
|
+
if (files.length === 0) {
|
|
304
|
+
throw new Error('no compatible local model files found for this link')
|
|
305
|
+
}
|
|
306
|
+
const selected = selectedFilename
|
|
307
|
+
? files.find(file => file.filename === selectedFilename)
|
|
308
|
+
: files[0]
|
|
309
|
+
if (!selected) {
|
|
310
|
+
throw new Error(`compatible file not found: ${selectedFilename}`)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const requestedRevision = ref.revision ?? DEFAULT_REVISION
|
|
314
|
+
const resolvedRevision = repo.sha || requestedRevision
|
|
315
|
+
const sizeBytes = selected.sizeBytes ?? 0
|
|
316
|
+
const review = reviewHfModel({
|
|
317
|
+
repo,
|
|
318
|
+
filename: selected.filename,
|
|
319
|
+
sizeBytes,
|
|
320
|
+
requestedRevision,
|
|
321
|
+
resolvedRevision,
|
|
322
|
+
})
|
|
323
|
+
return {
|
|
324
|
+
repo,
|
|
325
|
+
repoId: repo.repoId,
|
|
326
|
+
requestedRevision,
|
|
327
|
+
resolvedRevision,
|
|
328
|
+
filename: selected.filename,
|
|
329
|
+
sizeBytes,
|
|
330
|
+
localPath: localPathFor(repo.repoId, resolvedRevision, selected.filename),
|
|
331
|
+
displayName: displayNameFor(repo.repoId, selected.filename),
|
|
332
|
+
review,
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export function reviewHfModel(args: {
|
|
337
|
+
repo: HuggingFaceRepoInfo
|
|
338
|
+
filename: string
|
|
339
|
+
sizeBytes: number
|
|
340
|
+
requestedRevision: string
|
|
341
|
+
resolvedRevision: string
|
|
342
|
+
}): HfSafetyReview {
|
|
343
|
+
const format = fileFormat(args.filename)
|
|
344
|
+
const runtime: HfRuntime =
|
|
345
|
+
format === 'gguf' ? 'llama.cpp runnable'
|
|
346
|
+
: format === 'safetensors' ? 'download-only'
|
|
347
|
+
: 'unsupported'
|
|
348
|
+
const quantization = quantizationFromFilename(args.filename)
|
|
349
|
+
const task = taskFor(args.repo, args.filename)
|
|
350
|
+
const sizeClass = sizeClassFor(args.sizeBytes)
|
|
351
|
+
const credibility = credibilityFor(args.repo)
|
|
352
|
+
const pinned = COMMIT_RE.test(args.requestedRevision) || COMMIT_RE.test(args.resolvedRevision)
|
|
353
|
+
const repoHasRiskyFiles = args.repo.siblings.some(file => fileFormat(file.filename) === 'pickle/bin')
|
|
354
|
+
const reasons: string[] = []
|
|
355
|
+
|
|
356
|
+
if (format !== 'gguf') reasons.push('selected file is not compatible with local chat')
|
|
357
|
+
if (!pinned) reasons.push('revision is mutable')
|
|
358
|
+
if (!args.repo.license) reasons.push('license is missing or unknown')
|
|
359
|
+
if (credibility === 'low-signal') reasons.push('repo has limited public usage signals')
|
|
360
|
+
if (repoHasRiskyFiles) reasons.push('repo also contains pickle/bin model files')
|
|
361
|
+
|
|
362
|
+
const risk: HfRisk =
|
|
363
|
+
format !== 'gguf'
|
|
364
|
+
? 'high'
|
|
365
|
+
: repoHasRiskyFiles
|
|
366
|
+
? 'medium'
|
|
367
|
+
: pinned && args.repo.license && credibility !== 'low-signal'
|
|
368
|
+
? 'low'
|
|
369
|
+
: 'medium'
|
|
370
|
+
|
|
371
|
+
if (reasons.length === 0) reasons.push('compatible local model file with usable repo metadata')
|
|
372
|
+
|
|
373
|
+
return {
|
|
374
|
+
risk,
|
|
375
|
+
credibility,
|
|
376
|
+
format,
|
|
377
|
+
runtime,
|
|
378
|
+
task,
|
|
379
|
+
sizeClass,
|
|
380
|
+
quantization,
|
|
381
|
+
reasons,
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export async function* downloadHfModel(
|
|
386
|
+
plan: HfDownloadPlan,
|
|
387
|
+
signal?: AbortSignal,
|
|
388
|
+
fetchImpl: FetchImpl = fetch,
|
|
389
|
+
): AsyncIterable<HfDownloadProgress> {
|
|
390
|
+
if (plan.review.runtime !== 'llama.cpp runnable') {
|
|
391
|
+
throw new Error('selected file is not compatible with local chat')
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
await fs.mkdir(path.dirname(plan.localPath), { recursive: true })
|
|
395
|
+
const partialPath = `${plan.localPath}.partial`
|
|
396
|
+
const response = await fetchImpl(resolveUrl(plan.repoId, plan.resolvedRevision, plan.filename), { signal })
|
|
397
|
+
if (!response.ok || !response.body) {
|
|
398
|
+
throw new Error(response.ok ? 'empty download body' : `download HTTP ${response.status}`)
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const total = Number.parseInt(response.headers.get('content-length') ?? '', 10)
|
|
402
|
+
const hash = createHash('sha256')
|
|
403
|
+
const handle = await fs.open(partialPath, 'w')
|
|
404
|
+
let completed = 0
|
|
405
|
+
let complete = false
|
|
406
|
+
let lastProgressAt = Date.now()
|
|
407
|
+
let lastProgressBytes = 0
|
|
408
|
+
yield { status: 'starting', completed, total: Number.isFinite(total) ? total : undefined }
|
|
409
|
+
try {
|
|
410
|
+
const reader = response.body.getReader()
|
|
411
|
+
while (true) {
|
|
412
|
+
const { done, value } = await reader.read()
|
|
413
|
+
if (done) break
|
|
414
|
+
if (signal?.aborted) throw new Error('cancelled')
|
|
415
|
+
const buffer = Buffer.from(value)
|
|
416
|
+
hash.update(buffer)
|
|
417
|
+
await handle.write(buffer)
|
|
418
|
+
completed += buffer.byteLength
|
|
419
|
+
const now = Date.now()
|
|
420
|
+
if (shouldReportDownloadProgress(completed, lastProgressBytes, now, lastProgressAt)) {
|
|
421
|
+
lastProgressAt = now
|
|
422
|
+
lastProgressBytes = completed
|
|
423
|
+
yield { status: 'downloading', completed, total: Number.isFinite(total) ? total : undefined }
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
complete = true
|
|
427
|
+
} finally {
|
|
428
|
+
await handle.close()
|
|
429
|
+
if (!complete) {
|
|
430
|
+
await fs.unlink(partialPath).catch(() => {})
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
await fs.rename(partialPath, plan.localPath)
|
|
435
|
+
await upsertLocalHfModel(modelFromPlan(plan, hash.digest('hex'), 'ready'))
|
|
436
|
+
yield { status: 'success', completed, total: Number.isFinite(total) ? total : completed }
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export function shouldReportDownloadProgress(
|
|
440
|
+
completed: number,
|
|
441
|
+
lastCompleted: number,
|
|
442
|
+
nowMs: number,
|
|
443
|
+
lastReportedMs: number,
|
|
444
|
+
): boolean {
|
|
445
|
+
return nowMs - lastReportedMs >= DOWNLOAD_PROGRESS_MIN_MS
|
|
446
|
+
|| completed - lastCompleted >= DOWNLOAD_PROGRESS_MIN_BYTES
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export function modelFromPlan(plan: HfDownloadPlan, sha256: string | undefined, status: LocalHfStatus): LocalHfModel {
|
|
450
|
+
const now = new Date().toISOString()
|
|
451
|
+
return {
|
|
452
|
+
id: localModelId(plan.repoId, plan.filename),
|
|
453
|
+
provider: 'llamacpp',
|
|
454
|
+
repoId: plan.repoId,
|
|
455
|
+
requestedRevision: plan.requestedRevision,
|
|
456
|
+
resolvedRevision: plan.resolvedRevision,
|
|
457
|
+
filename: plan.filename,
|
|
458
|
+
displayName: plan.displayName,
|
|
459
|
+
localPath: plan.localPath,
|
|
460
|
+
sizeBytes: plan.sizeBytes,
|
|
461
|
+
format: plan.review.format,
|
|
462
|
+
runtime: plan.review.runtime,
|
|
463
|
+
task: plan.review.task,
|
|
464
|
+
sizeClass: plan.review.sizeClass,
|
|
465
|
+
quantization: plan.review.quantization,
|
|
466
|
+
risk: plan.review.risk,
|
|
467
|
+
credibility: plan.review.credibility,
|
|
468
|
+
license: plan.repo.license,
|
|
469
|
+
downloads: plan.repo.downloads,
|
|
470
|
+
likes: plan.repo.likes,
|
|
471
|
+
reviewedAt: now,
|
|
472
|
+
installedAt: now,
|
|
473
|
+
status,
|
|
474
|
+
sha256,
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
export function localModelId(repoId: string, filename: string): string {
|
|
479
|
+
return `${repoId}#${filename}`
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
export function displayNameFor(repoId: string, filename: string): string {
|
|
483
|
+
const basename = filename.split('/').pop() ?? filename
|
|
484
|
+
return `${repoId} / ${basename}`
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
export function fileFormat(filename: string): HfFileFormat {
|
|
488
|
+
const lower = filename.toLowerCase()
|
|
489
|
+
if (lower.endsWith('.gguf')) return 'gguf'
|
|
490
|
+
if (lower.endsWith('.safetensors')) return 'safetensors'
|
|
491
|
+
if (/\.(bin|pt|pth|pkl|pickle)$/.test(lower)) return 'pickle/bin'
|
|
492
|
+
return 'unknown'
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
export function quantizationFromFilename(filename: string): string | undefined {
|
|
496
|
+
const match = filename.toUpperCase().match(/(?:^|[-_.])((?:IQ|Q)\d(?:_[A-Z0-9]+)*|F16|BF16|F32)(?=$|[-_.])/)
|
|
497
|
+
return match?.[1]
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function localPathFor(repoId: string, revision: string, filename: string): string {
|
|
501
|
+
const repoParts = repoId.split('/').map(safePathPart)
|
|
502
|
+
const fileParts = filename.split('/').map(safePathPart)
|
|
503
|
+
return path.join(getLocalHfCacheDir(), ...repoParts, safePathPart(revision), ...fileParts)
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
async function unlinkIfPresent(
|
|
507
|
+
target: string,
|
|
508
|
+
unlink: (target: string) => Promise<void>,
|
|
509
|
+
): Promise<void> {
|
|
510
|
+
try {
|
|
511
|
+
await unlink(target)
|
|
512
|
+
} catch (err: unknown) {
|
|
513
|
+
const code = (err as NodeJS.ErrnoException).code
|
|
514
|
+
if (code === 'ENOENT') return
|
|
515
|
+
if (code === 'EBUSY' || code === 'EPERM' || code === 'EACCES') {
|
|
516
|
+
throw new Error('that model file is currently in use. stop the local runner and try uninstall again.')
|
|
517
|
+
}
|
|
518
|
+
throw err
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
async function cleanupEmptyParents(
|
|
523
|
+
startDir: string,
|
|
524
|
+
cacheRoot: string,
|
|
525
|
+
rmdir: (target: string) => Promise<void>,
|
|
526
|
+
): Promise<void> {
|
|
527
|
+
let current = path.resolve(startDir)
|
|
528
|
+
while (isPathInside(cacheRoot, current) && current !== cacheRoot) {
|
|
529
|
+
try {
|
|
530
|
+
await rmdir(current)
|
|
531
|
+
} catch (err: unknown) {
|
|
532
|
+
const code = (err as NodeJS.ErrnoException).code
|
|
533
|
+
if (code === 'ENOENT') {
|
|
534
|
+
current = path.dirname(current)
|
|
535
|
+
continue
|
|
536
|
+
}
|
|
537
|
+
if (code === 'ENOTEMPTY' || code === 'EEXIST' || code === 'EPERM') return
|
|
538
|
+
throw err
|
|
539
|
+
}
|
|
540
|
+
current = path.dirname(current)
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function isPathInside(root: string, target: string): boolean {
|
|
545
|
+
const relative = path.relative(root, target)
|
|
546
|
+
return relative.length > 0 && !relative.startsWith('..') && !path.isAbsolute(relative)
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function resolveUrl(repoId: string, revision: string, filename: string): string {
|
|
550
|
+
return `${HF_BASE_URL}/${encodeRepoPath(repoId)}/resolve/${encodeURIComponent(revision)}/${encodePath(filename)}?download=true`
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function encodeRepoPath(repoId: string): string {
|
|
554
|
+
return repoId.split('/').map(part => encodeURIComponent(part)).join('/')
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function encodePath(value: string): string {
|
|
558
|
+
return value.split('/').map(part => encodeURIComponent(part)).join('/')
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function safePathPart(value: string): string {
|
|
562
|
+
const cleaned = value.replace(/[^a-zA-Z0-9._-]/g, '_').replace(/^\.+$/, '_')
|
|
563
|
+
return cleaned || '_'
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function parseSibling(value: unknown): HuggingFaceSibling[] {
|
|
567
|
+
if (!value || typeof value !== 'object') return []
|
|
568
|
+
const record = value as { rfilename?: unknown; filename?: unknown; size?: unknown; lfs?: unknown }
|
|
569
|
+
const filename = typeof record.rfilename === 'string'
|
|
570
|
+
? record.rfilename
|
|
571
|
+
: typeof record.filename === 'string' ? record.filename : ''
|
|
572
|
+
if (!filename) return []
|
|
573
|
+
return [{
|
|
574
|
+
filename,
|
|
575
|
+
sizeBytes: siblingSizeBytes(record),
|
|
576
|
+
}]
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function parseSearchItem(value: unknown): HuggingFaceModelSearchItem[] {
|
|
580
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return []
|
|
581
|
+
const item = value as ModelSearchResponseItem
|
|
582
|
+
const repoId = typeof item.id === 'string'
|
|
583
|
+
? item.id
|
|
584
|
+
: typeof item.modelId === 'string' ? item.modelId : ''
|
|
585
|
+
if (!repoId.includes('/')) return []
|
|
586
|
+
return [{
|
|
587
|
+
repoId,
|
|
588
|
+
downloads: typeof item.downloads === 'number' ? item.downloads : undefined,
|
|
589
|
+
likes: typeof item.likes === 'number' ? item.likes : undefined,
|
|
590
|
+
lastModified: typeof item.lastModified === 'string' ? item.lastModified : undefined,
|
|
591
|
+
tags: Array.isArray(item.tags)
|
|
592
|
+
? item.tags.filter((tag): tag is string => typeof tag === 'string')
|
|
593
|
+
: [],
|
|
594
|
+
}]
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function siblingSizeBytes(record: { size?: unknown; lfs?: unknown }): number | undefined {
|
|
598
|
+
if (typeof record.size === 'number' && Number.isFinite(record.size) && record.size >= 0) {
|
|
599
|
+
return record.size
|
|
600
|
+
}
|
|
601
|
+
if (!record.lfs || typeof record.lfs !== 'object' || Array.isArray(record.lfs)) return undefined
|
|
602
|
+
const size = (record.lfs as { size?: unknown }).size
|
|
603
|
+
return typeof size === 'number' && Number.isFinite(size) && size >= 0 ? size : undefined
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function licenseFrom(cardData: unknown, tags: string[]): string | undefined {
|
|
607
|
+
if (cardData && typeof cardData === 'object' && !Array.isArray(cardData)) {
|
|
608
|
+
const license = (cardData as { license?: unknown }).license
|
|
609
|
+
if (typeof license === 'string' && license.trim()) return license.trim()
|
|
610
|
+
}
|
|
611
|
+
const tag = tags.find(item => item.startsWith('license:'))
|
|
612
|
+
return tag ? tag.slice('license:'.length) : undefined
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function credibilityFor(repo: HuggingFaceRepoInfo): HfCredibility {
|
|
616
|
+
const downloads = repo.downloads ?? 0
|
|
617
|
+
const likes = repo.likes ?? 0
|
|
618
|
+
if (downloads >= 10_000 || likes >= 100) return 'established'
|
|
619
|
+
if (downloads >= 100 || likes >= 5 || Boolean(repo.license)) return 'normal'
|
|
620
|
+
return 'low-signal'
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function taskFor(repo: HuggingFaceRepoInfo, filename: string): HfTask {
|
|
624
|
+
const haystack = [repo.repoId, filename, ...repo.tags].join(' ').toLowerCase()
|
|
625
|
+
if (/(embed|embedding)/.test(haystack)) return 'embedding'
|
|
626
|
+
if (/(vision|vlm|multimodal)/.test(haystack)) return 'vision'
|
|
627
|
+
if (/(code|coder|coding)/.test(haystack)) return 'code'
|
|
628
|
+
if (/(chat|instruct|assistant)/.test(haystack)) return 'chat/instruct'
|
|
629
|
+
if (/(base)/.test(haystack)) return 'base'
|
|
630
|
+
return 'unknown'
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function sizeClassFor(sizeBytes: number): HfSizeClass {
|
|
634
|
+
const gb = sizeBytes / 1e9
|
|
635
|
+
if (gb < 2) return 'tiny'
|
|
636
|
+
if (gb < 8) return 'small'
|
|
637
|
+
if (gb < 24) return 'medium'
|
|
638
|
+
return 'large'
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function isLocalHfModel(value: unknown): value is LocalHfModel {
|
|
642
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return false
|
|
643
|
+
const item = value as Partial<LocalHfModel>
|
|
644
|
+
return item.provider === 'llamacpp'
|
|
645
|
+
&& typeof item.id === 'string'
|
|
646
|
+
&& typeof item.repoId === 'string'
|
|
647
|
+
&& typeof item.filename === 'string'
|
|
648
|
+
&& typeof item.displayName === 'string'
|
|
649
|
+
&& typeof item.localPath === 'string'
|
|
650
|
+
&& item.status === 'ready'
|
|
651
|
+
}
|