ethagent 2.2.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -0
- package/package.json +2 -1
- package/src/app/FirstRun.tsx +3 -7
- package/src/app/FirstRunTimeline.tsx +1 -1
- package/src/chat/ChatBottomPane.tsx +29 -11
- package/src/chat/ChatScreen.tsx +169 -38
- package/src/chat/ConversationStack.tsx +1 -1
- package/src/chat/MessageList.tsx +185 -72
- package/src/chat/SessionStatus.tsx +3 -1
- package/src/chat/chatScreenUtils.ts +11 -15
- package/src/chat/chatSessionState.ts +5 -2
- package/src/chat/chatTurnOrchestrator.ts +7 -9
- package/src/chat/commands.ts +26 -26
- package/src/chat/display/DiffView.tsx +193 -0
- package/src/chat/display/SyntaxText.tsx +192 -0
- package/src/chat/display/toolCallDisplay.ts +103 -0
- package/src/chat/display/toolResultDisplay.ts +19 -0
- package/src/chat/{ChatInput.tsx → input/ChatInput.tsx} +61 -25
- package/src/chat/input/imageRefs.ts +30 -0
- package/src/chat/{TranscriptView.tsx → transcript/TranscriptView.tsx} +24 -50
- package/src/chat/{transcriptViewport.ts → transcript/transcriptViewport.ts} +12 -30
- package/src/chat/{ContextLimitView.tsx → views/ContextLimitView.tsx} +3 -3
- package/src/chat/{ContinuityEditReviewView.tsx → views/ContinuityEditReviewView.tsx} +11 -3
- package/src/chat/{CopyPicker.tsx → views/CopyPicker.tsx} +4 -5
- package/src/chat/{PermissionPrompt.tsx → views/PermissionPrompt.tsx} +16 -17
- package/src/chat/{PermissionsView.tsx → views/PermissionsView.tsx} +6 -6
- package/src/chat/{PlanApprovalView.tsx → views/PlanApprovalView.tsx} +4 -4
- package/src/chat/{ResumeView.tsx → views/ResumeView.tsx} +50 -41
- package/src/chat/views/RewindView.tsx +410 -0
- package/src/identity/continuity/privateEdit/diff.ts +2 -78
- package/src/identity/hub/OperationalRoutes.tsx +21 -21
- package/src/identity/hub/Routes.tsx +13 -13
- package/src/identity/hub/{flows/continuity → continuity}/ContinuityDashboardScreen.tsx +9 -9
- package/src/identity/hub/{flows/continuity → continuity}/RebackupStorageScreen.tsx +2 -2
- package/src/identity/hub/{flows/continuity → continuity}/RecoveryConfirmScreen.tsx +5 -5
- package/src/identity/hub/{flows/continuity → continuity}/SavePromptScreen.tsx +5 -5
- package/src/identity/hub/{effects/rebackup/runRebackup.ts → continuity/effects.ts} +17 -17
- package/src/identity/hub/{effects/rebackup → continuity}/index.ts +1 -1
- package/src/identity/hub/{effects/shared → continuity}/snapshot.ts +8 -8
- package/src/identity/hub/{effects/rebackup → continuity}/vault.ts +15 -15
- package/src/identity/hub/{flows/create → create}/CreateFlow.tsx +13 -13
- package/src/identity/hub/{effects/create.ts → create/effects.ts} +4 -4
- package/src/identity/hub/{flows/custody → custody}/CustodyEditFlow.tsx +9 -9
- package/src/identity/hub/{flows/custody/custodyFlowActions.ts → custody/actions.ts} +6 -6
- package/src/identity/hub/{flows/custody/custodyFlowHelpers.ts → custody/helpers.ts} +4 -4
- package/src/identity/hub/{effects/vault → custody}/preflight.ts +5 -5
- package/src/identity/hub/{flows/custody/custodyFlowRoutes.tsx → custody/routes.tsx} +8 -8
- package/src/identity/hub/{flows/custody/custodyEffects.ts → custody/transactions.ts} +9 -9
- package/src/identity/hub/{flows/custody/custodyFlowTypes.ts → custody/types.ts} +5 -5
- package/src/identity/hub/{flows/custody/custodyFlowEffects.ts → custody/useCustodyEffects.ts} +7 -7
- package/src/identity/hub/{flows/custody → custody}/useCustodyFlow.tsx +5 -5
- package/src/identity/hub/{flows/ens → ens}/EnsEditAdvancedScreens.tsx +13 -13
- package/src/identity/hub/{flows/ens → ens}/EnsEditFlow.tsx +7 -7
- package/src/identity/hub/{flows/ens → ens}/EnsEditMaintenanceScreens.tsx +10 -10
- package/src/identity/hub/{flows/ens → ens}/EnsEditReviewScreens.tsx +12 -12
- package/src/identity/hub/{flows/ens → ens}/EnsEditRunners.tsx +5 -5
- package/src/identity/hub/{flows/ens → ens}/EnsEditShared.tsx +10 -10
- package/src/identity/hub/{flows/ens → ens}/EnsEditSimpleScreens.tsx +14 -14
- package/src/identity/hub/{flows/ens/IdentityHubEnsFlow.tsx → ens/EnsFlow.tsx} +12 -12
- package/src/identity/hub/{flows/ens/OperatorWalletsScreen.tsx → ens/EnsOperatorWalletsScreen.tsx} +17 -17
- package/src/identity/hub/{advancedEnsValidation.ts → ens/advancedEnsValidation.ts} +2 -2
- package/src/identity/hub/{flows/ens/ensEditCopy.ts → ens/editCopy.ts} +3 -3
- package/src/identity/hub/{effects/ens/flows.ts → ens/effects.ts} +7 -7
- package/src/identity/hub/{effects/ens → ens}/index.ts +1 -1
- package/src/identity/hub/{model/ens.ts → ens/state.ts} +1 -1
- package/src/identity/hub/{effects/ens → ens}/transactions.ts +239 -239
- package/src/identity/hub/{flows/ens/ensEditTypes.ts → ens/types.ts} +7 -7
- package/src/identity/hub/identityHubReducer.ts +3 -3
- package/src/identity/hub/{flows/profile → profile}/EditProfileFlow.tsx +11 -11
- package/src/identity/hub/{effects/publicProfile/runPublicProfileSave.ts → profile/effects.ts} +18 -18
- package/src/identity/hub/{model → profile}/identity.ts +3 -3
- package/src/identity/hub/{effects/profile/profileState.ts → profile/state.ts} +181 -181
- package/src/identity/hub/{flows/restore → restore}/RestoreFlow.tsx +16 -16
- package/src/identity/hub/{effects/restore → restore}/apply.ts +10 -10
- package/src/identity/hub/{effects/restore → restore}/auth.ts +7 -7
- package/src/identity/hub/{effects/restore → restore}/discover.ts +6 -6
- package/src/identity/hub/{effects/restore → restore}/envelopes.ts +2 -2
- package/src/identity/hub/{effects/restore → restore}/fetch.ts +3 -3
- package/src/identity/hub/{effects/restore/shared.ts → restore/helpers.ts} +6 -6
- package/src/identity/hub/{effects/restore → restore}/recovery.ts +10 -10
- package/src/identity/hub/{effects/restore → restore}/resolve.ts +4 -4
- package/src/identity/hub/{effects → restore}/restoreAdmin.ts +1 -1
- package/src/identity/hub/{flows/restore/useRestoreFlowEffects.ts → restore/useRestoreEffects.ts} +5 -5
- package/src/identity/hub/{flows/settings → settings}/StorageCredentialScreen.tsx +5 -5
- package/src/identity/hub/{components → shared/components}/BusyScreen.tsx +4 -4
- package/src/identity/hub/{components → shared/components}/DetailsScreen.tsx +4 -4
- package/src/identity/hub/{components → shared/components}/ErrorScreen.tsx +4 -4
- package/src/identity/hub/{components → shared/components}/FlowTimeline.tsx +1 -1
- package/src/identity/hub/{components → shared/components}/IdentitySummary.tsx +8 -8
- package/src/identity/hub/{components → shared/components}/MenuScreen.tsx +7 -7
- package/src/identity/hub/{components → shared/components}/NetworkScreen.tsx +4 -4
- package/src/identity/hub/{components → shared/components}/PinataJwtInput.tsx +4 -4
- package/src/identity/hub/{components → shared/components}/UnlinkedIdentityScreen.tsx +5 -5
- package/src/identity/hub/{components → shared/components}/WalletApprovalScreen.tsx +6 -6
- package/src/identity/hub/{components → shared/components}/menuFlagsFromReconciliation.ts +1 -1
- package/src/identity/hub/{effects/shared → shared/effects}/profilePrep.ts +1 -1
- package/src/identity/hub/{effects → shared/effects}/receipts.ts +2 -2
- package/src/identity/hub/{effects/shared → shared/effects}/sync.ts +4 -4
- package/src/identity/hub/{effects → shared/effects}/types.ts +3 -3
- package/src/identity/hub/{model → shared/model}/copy.ts +2 -2
- package/src/identity/hub/{model → shared/model}/errors.ts +5 -5
- package/src/identity/hub/{model → shared/model}/network.ts +3 -3
- package/src/identity/hub/{operatorWallets.ts → shared/operatorWallets.ts} +1 -1
- package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/hook.ts +1 -1
- package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/ownership.ts +2 -2
- package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/run.ts +6 -6
- package/src/identity/hub/{utils.ts → shared/utils.ts} +5 -5
- package/src/identity/hub/{flows/token-transfer/IdentityHubTokenTransferFlow.tsx → transfer/TokenTransferFlow.tsx} +8 -8
- package/src/identity/hub/{flows/token-transfer → transfer}/TokenTransferScreens.tsx +14 -14
- package/src/identity/hub/{effects/token-transfer/runTokenTransfer.ts → transfer/effects.ts} +16 -16
- package/src/identity/hub/{effects/token-transfer → transfer}/progress.ts +1 -1
- package/src/identity/hub/useIdentityHubController.ts +11 -11
- package/src/identity/hub/useIdentityHubSideEffects.ts +11 -11
- package/src/models/ModelPicker.tsx +143 -9
- package/src/models/catalog.ts +2 -1
- package/src/models/huggingface.ts +180 -2
- package/src/models/llamacpp.ts +110 -15
- package/src/models/llamacppPreflight.ts +30 -11
- package/src/models/modelPickerOptions.ts +16 -15
- package/src/models/providerDisplay.ts +16 -0
- package/src/providers/anthropic.ts +36 -5
- package/src/providers/contracts.ts +9 -1
- package/src/providers/errors.ts +6 -4
- package/src/providers/gemini.ts +29 -3
- package/src/providers/openai-chat.ts +83 -3
- package/src/providers/openai-responses-format.ts +29 -8
- package/src/providers/openai-responses.ts +22 -7
- package/src/providers/registry.ts +1 -0
- package/src/runtime/sessionMode.ts +1 -1
- package/src/runtime/systemPrompt.ts +3 -1
- package/src/runtime/toolExecution.ts +9 -6
- package/src/runtime/turn.ts +29 -0
- package/src/storage/config.ts +1 -0
- package/src/storage/rewind.ts +20 -0
- package/src/storage/sessions.ts +16 -3
- package/src/tools/bashSafety.ts +7 -3
- package/src/tools/bashTool.ts +1 -1
- package/src/tools/contracts.ts +3 -0
- package/src/tools/deleteFileTool.ts +8 -3
- package/src/tools/editTool.ts +10 -5
- package/src/tools/fileDiff.ts +261 -0
- package/src/tools/privateContinuityEditTool.ts +5 -1
- package/src/tools/writeFileTool.ts +8 -3
- package/src/ui/Spinner.tsx +39 -5
- package/src/ui/TextInput.tsx +2 -2
- package/src/ui/theme.ts +19 -0
- package/src/utils/clipboard.ts +10 -7
- package/src/utils/images.ts +140 -0
- package/src/utils/messages.ts +2 -0
- package/src/chat/RewindView.tsx +0 -386
- package/src/chat/toolResultDisplay.ts +0 -8
- package/src/identity/hub/effects/index.ts +0 -73
- package/src/identity/hub/effects/publicProfile/index.ts +0 -5
- package/src/identity/hub/effects/restore/restoreEffects.ts +0 -22
- package/src/identity/hub/effects/token-transfer/index.ts +0 -6
- /package/src/chat/{chatInputState.ts → input/chatInputState.ts} +0 -0
- /package/src/chat/{chatPaste.ts → input/chatPaste.ts} +0 -0
- /package/src/chat/{textCursor.ts → input/textCursor.ts} +0 -0
- /package/src/identity/hub/{model/continuity.ts → continuity/state.ts} +0 -0
- /package/src/identity/hub/{model/custody.ts → custody/state.ts} +0 -0
- /package/src/identity/hub/{effects/restore → restore}/index.ts +0 -0
- /package/src/identity/hub/{model → shared/model}/format.ts +0 -0
- /package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/types.ts +0 -0
- /package/src/identity/hub/{reconciliation → shared/reconciliation}/index.ts +0 -0
- /package/src/identity/hub/{reconciliation → shared/reconciliation}/useAgentReconciliation.ts +0 -0
- /package/src/identity/hub/{reconciliation → shared/reconciliation}/walletSetup.ts +0 -0
- /package/src/identity/hub/{txGuard.ts → shared/txGuard.ts} +0 -0
- /package/src/identity/hub/{model/transfer.ts → transfer/state.ts} +0 -0
package/src/models/llamacpp.ts
CHANGED
|
@@ -50,6 +50,7 @@ export type LlamaCppStartFailureCode =
|
|
|
50
50
|
| 'spawn-failed'
|
|
51
51
|
| 'runner-exited'
|
|
52
52
|
| 'readiness-timeout'
|
|
53
|
+
| 'untracked-server'
|
|
53
54
|
|
|
54
55
|
export type LlamaCppStartResult =
|
|
55
56
|
| { ok: true; alreadyRunning: boolean }
|
|
@@ -362,25 +363,45 @@ export async function startLlamaCppServer(args: {
|
|
|
362
363
|
modelAlias: string
|
|
363
364
|
host?: string
|
|
364
365
|
ctxSize?: number
|
|
366
|
+
mmprojPath?: string
|
|
365
367
|
readinessTimeoutMs?: number
|
|
366
368
|
pollMs?: number
|
|
367
369
|
deps?: LlamaCppStartDeps
|
|
368
370
|
}): Promise<LlamaCppStartResult> {
|
|
369
371
|
const host = args.host ?? DEFAULT_LLAMA_HOST
|
|
370
372
|
const initialStatus = await servedModelStatus(host, args.modelAlias)
|
|
371
|
-
if (initialStatus.state === 'ready')
|
|
373
|
+
if (initialStatus.state === 'ready') {
|
|
374
|
+
if (args.mmprojPath) {
|
|
375
|
+
const pid = await readPidFile()
|
|
376
|
+
if (!pid) {
|
|
377
|
+
return startFailure('untracked-server', {
|
|
378
|
+
detail: 'A llama-server is already serving this alias but ethagent did not launch it, so we cannot apply the vision projector. Stop the external process and reopen ethagent.',
|
|
379
|
+
})
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return { ok: true, alreadyRunning: true }
|
|
383
|
+
}
|
|
372
384
|
if (initialStatus.state === 'different') {
|
|
373
385
|
return startFailure('different-model-running', {
|
|
374
386
|
servedModels: initialStatus.models,
|
|
375
387
|
})
|
|
376
388
|
}
|
|
377
389
|
|
|
390
|
+
const accessFn = args.deps?.access ?? fs.access
|
|
378
391
|
try {
|
|
379
|
-
await (args.
|
|
392
|
+
await accessFn(args.modelPath)
|
|
380
393
|
} catch {
|
|
381
394
|
return startFailure('model-file-missing', { detail: args.modelPath })
|
|
382
395
|
}
|
|
383
396
|
|
|
397
|
+
if (args.mmprojPath) {
|
|
398
|
+
try {
|
|
399
|
+
await accessFn(args.mmprojPath)
|
|
400
|
+
} catch {
|
|
401
|
+
return startFailure('model-file-missing', { detail: args.mmprojPath })
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
384
405
|
const binaryPath = args.deps?.binaryPath ?? (await findAndPersistLlamaCppServer()).path
|
|
385
406
|
if (!binaryPath) {
|
|
386
407
|
return startFailure('runner-not-installed')
|
|
@@ -390,21 +411,23 @@ export async function startLlamaCppServer(args: {
|
|
|
390
411
|
const listenHost = url.hostname || '127.0.0.1'
|
|
391
412
|
const port = url.port || (url.protocol === 'https:' ? '443' : '8080')
|
|
392
413
|
const spawnImpl = args.deps?.spawnImpl ?? spawn
|
|
414
|
+
const spawnArgs: string[] = [
|
|
415
|
+
'-m',
|
|
416
|
+
args.modelPath,
|
|
417
|
+
'--host',
|
|
418
|
+
listenHost,
|
|
419
|
+
'--port',
|
|
420
|
+
port,
|
|
421
|
+
'--alias',
|
|
422
|
+
args.modelAlias,
|
|
423
|
+
'--ctx-size',
|
|
424
|
+
String(args.ctxSize ?? 32768),
|
|
425
|
+
'--jinja',
|
|
426
|
+
]
|
|
427
|
+
if (args.mmprojPath) spawnArgs.push('--mmproj', args.mmprojPath)
|
|
393
428
|
let child: ReturnType<typeof spawn>
|
|
394
429
|
try {
|
|
395
|
-
child = spawnImpl(binaryPath,
|
|
396
|
-
'-m',
|
|
397
|
-
args.modelPath,
|
|
398
|
-
'--host',
|
|
399
|
-
listenHost,
|
|
400
|
-
'--port',
|
|
401
|
-
port,
|
|
402
|
-
'--alias',
|
|
403
|
-
args.modelAlias,
|
|
404
|
-
'--ctx-size',
|
|
405
|
-
String(args.ctxSize ?? 32768),
|
|
406
|
-
'--jinja',
|
|
407
|
-
], {
|
|
430
|
+
child = spawnImpl(binaryPath, spawnArgs, {
|
|
408
431
|
detached: true,
|
|
409
432
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
410
433
|
windowsHide: true,
|
|
@@ -424,6 +447,9 @@ export async function startLlamaCppServer(args: {
|
|
|
424
447
|
})
|
|
425
448
|
})
|
|
426
449
|
child.unref()
|
|
450
|
+
if (typeof child.pid === 'number') {
|
|
451
|
+
await writePidFile(child.pid).catch(() => {})
|
|
452
|
+
}
|
|
427
453
|
|
|
428
454
|
const ready = await waitForServedModel({
|
|
429
455
|
host,
|
|
@@ -468,6 +494,73 @@ async function waitForServedModel(args: {
|
|
|
468
494
|
return startFailure('readiness-timeout')
|
|
469
495
|
}
|
|
470
496
|
|
|
497
|
+
function pidFilePath(): string {
|
|
498
|
+
return path.join(getConfigDir(), 'llamacpp.pid')
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
async function writePidFile(pid: number): Promise<void> {
|
|
502
|
+
await ensureConfigDir()
|
|
503
|
+
await atomicWriteText(pidFilePath(), String(pid))
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
async function readPidFile(): Promise<number | null> {
|
|
507
|
+
try {
|
|
508
|
+
const raw = await fs.readFile(pidFilePath(), 'utf8')
|
|
509
|
+
const pid = Number.parseInt(raw.trim(), 10)
|
|
510
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null
|
|
511
|
+
} catch {
|
|
512
|
+
return null
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
async function clearPidFile(): Promise<void> {
|
|
517
|
+
await fs.rm(pidFilePath(), { force: true }).catch(() => {})
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
export async function stopLlamaCppServer(args: {
|
|
521
|
+
host?: string
|
|
522
|
+
timeoutMs?: number
|
|
523
|
+
pollMs?: number
|
|
524
|
+
killImpl?: (pid: number, signal?: NodeJS.Signals | number) => void
|
|
525
|
+
} = {}): Promise<
|
|
526
|
+
| { ok: true; stopped: boolean; reason?: 'untracked-server'; servedModels?: string[] }
|
|
527
|
+
| { ok: false; message: string }
|
|
528
|
+
> {
|
|
529
|
+
const pid = await readPidFile()
|
|
530
|
+
if (!pid) {
|
|
531
|
+
const host = args.host ?? DEFAULT_LLAMA_HOST
|
|
532
|
+
const { up, models } = await fetchServedModels(host, 1500)
|
|
533
|
+
if (up && models.length > 0) {
|
|
534
|
+
return { ok: true, stopped: false, reason: 'untracked-server', servedModels: models }
|
|
535
|
+
}
|
|
536
|
+
return { ok: true, stopped: false }
|
|
537
|
+
}
|
|
538
|
+
const kill = args.killImpl ?? ((p, signal) => process.kill(p, signal))
|
|
539
|
+
try {
|
|
540
|
+
kill(pid, 'SIGTERM')
|
|
541
|
+
} catch (err: unknown) {
|
|
542
|
+
const code = (err as NodeJS.ErrnoException).code
|
|
543
|
+
if (code === 'ESRCH') {
|
|
544
|
+
await clearPidFile()
|
|
545
|
+
return { ok: true, stopped: false }
|
|
546
|
+
}
|
|
547
|
+
return { ok: false, message: (err as Error).message }
|
|
548
|
+
}
|
|
549
|
+
const host = args.host ?? DEFAULT_LLAMA_HOST
|
|
550
|
+
const deadline = Date.now() + (args.timeoutMs ?? 5000)
|
|
551
|
+
const pollMs = args.pollMs ?? 250
|
|
552
|
+
while (Date.now() < deadline) {
|
|
553
|
+
const status = await servedModelStatus(host, '__nothing__')
|
|
554
|
+
if (status.state === 'not-up' || status.models.length === 0) {
|
|
555
|
+
await clearPidFile()
|
|
556
|
+
return { ok: true, stopped: true }
|
|
557
|
+
}
|
|
558
|
+
await new Promise<void>(resolve => setTimeout(resolve, pollMs))
|
|
559
|
+
}
|
|
560
|
+
await clearPidFile()
|
|
561
|
+
return { ok: true, stopped: true }
|
|
562
|
+
}
|
|
563
|
+
|
|
471
564
|
async function servedModelStatus(host: string, modelAlias: string): Promise<
|
|
472
565
|
| { state: 'not-up'; models: string[] }
|
|
473
566
|
| { state: 'ready'; models: string[] }
|
|
@@ -507,6 +600,8 @@ function startFailureMessage(code: LlamaCppStartFailureCode, servedModels: strin
|
|
|
507
600
|
return 'local runner closed before becoming ready'
|
|
508
601
|
case 'readiness-timeout':
|
|
509
602
|
return 'local runner is still loading or did not answer in time'
|
|
603
|
+
case 'untracked-server':
|
|
604
|
+
return detail ?? 'a llama-server is already running and ethagent did not launch it; cannot apply the vision encoder until that process is stopped'
|
|
510
605
|
}
|
|
511
606
|
}
|
|
512
607
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
startLlamaCppServer,
|
|
3
|
+
stopLlamaCppServer,
|
|
3
4
|
type LlamaCppStartFailureCode,
|
|
4
5
|
type LlamaCppStartResult,
|
|
5
6
|
} from './llamacpp.js'
|
|
@@ -21,9 +22,13 @@ export type LlamaCppPreflightDeps = {
|
|
|
21
22
|
fetchImpl?: typeof fetch
|
|
22
23
|
findLocalModel?: typeof findLocalHfModel
|
|
23
24
|
startServer?: typeof startLlamaCppServer
|
|
25
|
+
stopServer?: typeof stopLlamaCppServer
|
|
24
26
|
timeoutMs?: number
|
|
25
27
|
}
|
|
26
28
|
|
|
29
|
+
const UNTRACKED_VISION_DETAIL =
|
|
30
|
+
'A llama-server is already serving this alias but ethagent did not launch it, so we cannot apply the vision projector. Stop the external process and reopen ethagent.'
|
|
31
|
+
|
|
27
32
|
type ModelsProbe =
|
|
28
33
|
| { up: true; models: string[] }
|
|
29
34
|
| { up: false; models: [] }
|
|
@@ -50,18 +55,31 @@ export async function ensureLlamaCppRunnerReady(
|
|
|
50
55
|
|
|
51
56
|
const probe = await probeLlamaCppModels(baseUrl, deps)
|
|
52
57
|
if (probe.up) {
|
|
53
|
-
if (probe.models.length
|
|
54
|
-
return {
|
|
58
|
+
if (probe.models.length > 0 && !probe.models.includes(config.model)) {
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
code: 'different-model-running',
|
|
62
|
+
message: formatPreflightFailure(
|
|
63
|
+
'local runner is serving a different model',
|
|
64
|
+
config.model,
|
|
65
|
+
`a different local model is already running (${probe.models.join(', ')}); stop it before switching models`,
|
|
66
|
+
),
|
|
67
|
+
servedModels: probe.models,
|
|
68
|
+
}
|
|
55
69
|
}
|
|
56
|
-
return {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
70
|
+
if (!local.mmprojPath) return { ok: true, alreadyRunning: true }
|
|
71
|
+
const stopped = await (deps.stopServer ?? stopLlamaCppServer)().catch(() => null)
|
|
72
|
+
if (stopped && stopped.ok && stopped.reason === 'untracked-server') {
|
|
73
|
+
return withPreflightMessage(
|
|
74
|
+
{
|
|
75
|
+
ok: false,
|
|
76
|
+
code: 'untracked-server',
|
|
77
|
+
message: UNTRACKED_VISION_DETAIL,
|
|
78
|
+
detail: UNTRACKED_VISION_DETAIL,
|
|
79
|
+
servedModels: stopped.servedModels,
|
|
80
|
+
},
|
|
81
|
+
local,
|
|
82
|
+
)
|
|
65
83
|
}
|
|
66
84
|
}
|
|
67
85
|
|
|
@@ -69,6 +87,7 @@ export async function ensureLlamaCppRunnerReady(
|
|
|
69
87
|
modelPath: local.localPath,
|
|
70
88
|
modelAlias: local.id,
|
|
71
89
|
host: llamaCppServerHostFromBaseUrl(baseUrl),
|
|
90
|
+
mmprojPath: local.mmprojPath,
|
|
72
91
|
})
|
|
73
92
|
if (result.ok) return { ok: true, alreadyRunning: result.alreadyRunning }
|
|
74
93
|
return withPreflightMessage(result, local)
|
|
@@ -7,26 +7,14 @@ import { type SelectOption } from '../ui/Select.js'
|
|
|
7
7
|
import { formatLocalHfModelDisplayName, formatModelDisplayName } from './modelDisplay.js'
|
|
8
8
|
import { localModelId, quantizationFromFilename } from './huggingface.js'
|
|
9
9
|
import type { UncensoredCatalogEntry } from './uncensoredCatalog.js'
|
|
10
|
+
import { cloudProviderDisplayName, providerDisplayName, type CloudProviderId } from './providerDisplay.js'
|
|
10
11
|
|
|
11
|
-
export type CloudProviderId
|
|
12
|
+
export { cloudProviderDisplayName, providerDisplayName, type CloudProviderId }
|
|
12
13
|
|
|
13
14
|
export const MODEL_PICKER_CLOUD_PROVIDERS: CloudProviderId[] = ['openai', 'anthropic', 'gemini']
|
|
14
15
|
export const LOCAL_MODEL_LINK_HINT = 'Paste a GGUF link'
|
|
15
16
|
export const LOCAL_MODEL_LINK_EXAMPLE = 'e.g. https://huggingface.co/Qwen/Qwen3-8B-GGUF'
|
|
16
17
|
|
|
17
|
-
export function cloudProviderDisplayName(provider: CloudProviderId): string {
|
|
18
|
-
switch (provider) {
|
|
19
|
-
case 'openai': return 'OpenAI'
|
|
20
|
-
case 'anthropic': return 'Anthropic'
|
|
21
|
-
case 'gemini': return 'Gemini'
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function providerDisplayName(provider: ProviderId): string {
|
|
26
|
-
if (provider === 'llamacpp') return 'llama.cpp'
|
|
27
|
-
return cloudProviderDisplayName(provider)
|
|
28
|
-
}
|
|
29
|
-
|
|
30
18
|
export type LocalHfPickerModel = {
|
|
31
19
|
id: string
|
|
32
20
|
displayName: string
|
|
@@ -35,6 +23,9 @@ export type LocalHfPickerModel = {
|
|
|
35
23
|
risk: HfRisk
|
|
36
24
|
task: HfTask
|
|
37
25
|
status: 'ready' | 'incomplete'
|
|
26
|
+
mmprojPath?: string
|
|
27
|
+
mmprojAvailable?: boolean
|
|
28
|
+
mmprojSizeBytes?: number
|
|
38
29
|
}
|
|
39
30
|
|
|
40
31
|
export type CloudCredentialKind = 'apikey' | 'oauth'
|
|
@@ -197,12 +188,22 @@ function appendHfModelOptions(
|
|
|
197
188
|
displayName: model.displayName,
|
|
198
189
|
maxLength,
|
|
199
190
|
})
|
|
191
|
+
const tags = ['Installed']
|
|
192
|
+
if (model.mmprojPath) tags.push('Vision encoder loaded')
|
|
200
193
|
options.push(rowOption(
|
|
201
194
|
`hf:${id}`,
|
|
202
195
|
contextFitLabel('llamacpp', id, `${active ? '* ' : ' '}${displayName}`, context.contextFit),
|
|
203
196
|
undefined,
|
|
204
|
-
modelMetadataSubtext(size,
|
|
197
|
+
modelMetadataSubtext(size, tags),
|
|
205
198
|
))
|
|
199
|
+
if (model.mmprojAvailable && !model.mmprojPath) {
|
|
200
|
+
const projectorSize = model.mmprojSizeBytes ? ` (+${formatSize(model.mmprojSizeBytes)})` : ''
|
|
201
|
+
options.push(rowOption(
|
|
202
|
+
`hfmmproj:${id}`,
|
|
203
|
+
` + Add Vision Encoder${projectorSize}`,
|
|
204
|
+
'Enable image input on this local model',
|
|
205
|
+
))
|
|
206
|
+
}
|
|
206
207
|
}
|
|
207
208
|
}
|
|
208
209
|
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ProviderId } from '../storage/config.js'
|
|
2
|
+
|
|
3
|
+
export type CloudProviderId = Exclude<ProviderId, 'llamacpp'>
|
|
4
|
+
|
|
5
|
+
export function cloudProviderDisplayName(provider: CloudProviderId): string {
|
|
6
|
+
switch (provider) {
|
|
7
|
+
case 'openai': return 'OpenAI'
|
|
8
|
+
case 'anthropic': return 'Anthropic'
|
|
9
|
+
case 'gemini': return 'Gemini'
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function providerDisplayName(provider: ProviderId): string {
|
|
14
|
+
if (provider === 'llamacpp') return 'llama.cpp'
|
|
15
|
+
return cloudProviderDisplayName(provider)
|
|
16
|
+
}
|
|
@@ -4,6 +4,7 @@ import { ProviderError } from './contracts.js'
|
|
|
4
4
|
import { providerErrorFromResponse } from './errors.js'
|
|
5
5
|
import { fetchWithRetryStreamEvents } from './retry.js'
|
|
6
6
|
import { iterSseEvents } from './sse.js'
|
|
7
|
+
import { hasImageBlocks, ImageLoadError, loadImageBlock } from '../utils/images.js'
|
|
7
8
|
|
|
8
9
|
export type AnthropicToolDefinition = {
|
|
9
10
|
name: string
|
|
@@ -75,7 +76,22 @@ export class AnthropicProvider implements Provider {
|
|
|
75
76
|
return
|
|
76
77
|
}
|
|
77
78
|
|
|
78
|
-
|
|
79
|
+
if (hasImageBlocks(messages) && !supportsAnthropicImages(this.model)) {
|
|
80
|
+
yield { type: 'error', message: `image input is not enabled for ${this.model}` }
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let split: { system?: string; conversation: Awaited<ReturnType<typeof splitMessages>>['conversation'] }
|
|
85
|
+
try {
|
|
86
|
+
split = await splitMessages(messages)
|
|
87
|
+
} catch (err: unknown) {
|
|
88
|
+
if (err instanceof ImageLoadError) {
|
|
89
|
+
yield { type: 'error', message: err.message }
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
throw err
|
|
93
|
+
}
|
|
94
|
+
const { system, conversation } = split
|
|
79
95
|
|
|
80
96
|
let response: Response
|
|
81
97
|
try {
|
|
@@ -195,22 +211,24 @@ export class AnthropicProvider implements Provider {
|
|
|
195
211
|
}
|
|
196
212
|
}
|
|
197
213
|
|
|
198
|
-
function splitMessages(messages: Message[]): {
|
|
214
|
+
async function splitMessages(messages: Message[]): Promise<{
|
|
199
215
|
system?: string
|
|
200
216
|
conversation: Array<{
|
|
201
217
|
role: 'user' | 'assistant'
|
|
202
218
|
content: Array<
|
|
203
219
|
| { type: 'text'; text: string }
|
|
220
|
+
| { type: 'image'; source: { type: 'base64'; media_type: string; data: string } }
|
|
204
221
|
| { type: 'tool_use'; id: string; name: string; input: Record<string, unknown> }
|
|
205
222
|
| { type: 'tool_result'; tool_use_id: string; content: string; is_error?: boolean }
|
|
206
223
|
>
|
|
207
224
|
}>
|
|
208
|
-
} {
|
|
225
|
+
}> {
|
|
209
226
|
const systemParts: string[] = []
|
|
210
227
|
const conversation: Array<{
|
|
211
228
|
role: 'user' | 'assistant'
|
|
212
229
|
content: Array<
|
|
213
230
|
| { type: 'text'; text: string }
|
|
231
|
+
| { type: 'image'; source: { type: 'base64'; media_type: string; data: string } }
|
|
214
232
|
| { type: 'tool_use'; id: string; name: string; input: Record<string, unknown> }
|
|
215
233
|
| { type: 'tool_result'; tool_use_id: string; content: string; is_error?: boolean }
|
|
216
234
|
>
|
|
@@ -226,11 +244,16 @@ function splitMessages(messages: Message[]): {
|
|
|
226
244
|
}
|
|
227
245
|
conversation.push({
|
|
228
246
|
role: message.role,
|
|
229
|
-
content: blocks.map(block => {
|
|
247
|
+
content: await Promise.all(blocks.map(async block => {
|
|
230
248
|
if (block.type === 'text') return { type: 'text', text: block.text }
|
|
249
|
+
if (block.type === 'image') {
|
|
250
|
+
const loaded = await loadImageBlock(block)
|
|
251
|
+
if (!loaded.dataBase64 || !loaded.mimeType) throw new Error(`could not load image: ${block.path}`)
|
|
252
|
+
return { type: 'image', source: { type: 'base64', media_type: loaded.mimeType, data: loaded.dataBase64 } }
|
|
253
|
+
}
|
|
231
254
|
if (block.type === 'tool_use') return { type: 'tool_use', id: block.id, name: block.name, input: block.input }
|
|
232
255
|
return { type: 'tool_result', tool_use_id: block.toolUseId, content: block.content, is_error: block.isError }
|
|
233
|
-
}),
|
|
256
|
+
})),
|
|
234
257
|
})
|
|
235
258
|
}
|
|
236
259
|
|
|
@@ -251,6 +274,14 @@ function normalizeBlocks(content: Message['content']): MessageContentBlock[] {
|
|
|
251
274
|
})
|
|
252
275
|
}
|
|
253
276
|
|
|
277
|
+
export function supportsAnthropicImages(model: string): boolean {
|
|
278
|
+
const normalized = model.toLowerCase()
|
|
279
|
+
return normalized.includes('claude-3')
|
|
280
|
+
|| normalized.includes('claude-sonnet-4')
|
|
281
|
+
|| normalized.includes('claude-opus-4')
|
|
282
|
+
|| normalized.includes('claude-haiku-4')
|
|
283
|
+
}
|
|
284
|
+
|
|
254
285
|
function normalizeStopReason(value?: string): 'end_turn' | 'tool_use' | 'max_tokens' | 'stop_sequence' | 'unknown' {
|
|
255
286
|
if (value === 'end_turn' || value === 'tool_use' || value === 'max_tokens' || value === 'stop_sequence') {
|
|
256
287
|
return value
|
|
@@ -8,6 +8,14 @@ export type TextBlock = {
|
|
|
8
8
|
text: string
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
export type ImageBlock = {
|
|
12
|
+
type: 'image'
|
|
13
|
+
path: string
|
|
14
|
+
mimeType?: string
|
|
15
|
+
url?: string
|
|
16
|
+
dataBase64?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
11
19
|
export type ToolUseBlock = {
|
|
12
20
|
type: 'tool_use'
|
|
13
21
|
id: string
|
|
@@ -22,7 +30,7 @@ export type ToolResultBlock = {
|
|
|
22
30
|
isError?: boolean
|
|
23
31
|
}
|
|
24
32
|
|
|
25
|
-
export type MessageContentBlock = TextBlock | ToolUseBlock | ToolResultBlock
|
|
33
|
+
export type MessageContentBlock = TextBlock | ImageBlock | ToolUseBlock | ToolResultBlock
|
|
26
34
|
|
|
27
35
|
export type Message = {
|
|
28
36
|
role: Role
|
package/src/providers/errors.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ProviderId } from '../storage/config.js'
|
|
2
2
|
import { ProviderError } from './contracts.js'
|
|
3
3
|
import { formatGeminiRateLimitMessage } from './gemini.js'
|
|
4
|
+
import { providerDisplayName } from '../models/providerDisplay.js'
|
|
4
5
|
|
|
5
6
|
type ErrorBody =
|
|
6
7
|
| string
|
|
@@ -30,19 +31,20 @@ export async function providerErrorFromResponse(
|
|
|
30
31
|
&& /API[_ ]?key( not valid| not found|_invalid)|invalid api key/i.test(detail)
|
|
31
32
|
) {
|
|
32
33
|
return new ProviderError(
|
|
33
|
-
'
|
|
34
|
+
'Gemini: API key rejected — verify your key at https://aistudio.google.com/app/apikey, then run /key gemini to set it again',
|
|
34
35
|
)
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
if (provider !== 'llamacpp') {
|
|
39
|
+
const name = providerDisplayName(provider)
|
|
38
40
|
if (response.status === 401 || response.status === 403) {
|
|
39
|
-
return new ProviderError(`auth failed: check your ${
|
|
41
|
+
return new ProviderError(`auth failed: check your ${name} key (/doctor to verify)`)
|
|
40
42
|
}
|
|
41
43
|
if (response.status === 429) {
|
|
42
|
-
return new ProviderError(detail || `${
|
|
44
|
+
return new ProviderError(detail || `${name} rate limit exceeded`, { transient: true })
|
|
43
45
|
}
|
|
44
46
|
if (response.status >= 500) {
|
|
45
|
-
return new ProviderError(detail || `${
|
|
47
|
+
return new ProviderError(detail || `${name} server error (${response.status})`, { transient: true })
|
|
46
48
|
}
|
|
47
49
|
}
|
|
48
50
|
|
package/src/providers/gemini.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { ProviderError } from './contracts.js'
|
|
|
4
4
|
import { providerErrorFromResponse } from './errors.js'
|
|
5
5
|
import { fetchWithRetryStreamEvents } from './retry.js'
|
|
6
6
|
import { iterSseFrames } from './sse.js'
|
|
7
|
+
import { hasImageBlocks, ImageLoadError, loadImageBlock } from '../utils/images.js'
|
|
7
8
|
|
|
8
9
|
export type GeminiToolDefinition = {
|
|
9
10
|
name: string
|
|
@@ -41,6 +42,7 @@ type GeminiChunk = {
|
|
|
41
42
|
|
|
42
43
|
type GeminiContentPart =
|
|
43
44
|
| { text: string }
|
|
45
|
+
| { inlineData: { mimeType: string; data: string } }
|
|
44
46
|
| { functionCall: { name: string; args: Record<string, unknown> } }
|
|
45
47
|
| { functionResponse: { name: string; response: Record<string, unknown> } }
|
|
46
48
|
|
|
@@ -92,8 +94,21 @@ export class GeminiProvider implements Provider {
|
|
|
92
94
|
yield { type: 'error', message: error.message }
|
|
93
95
|
return
|
|
94
96
|
}
|
|
97
|
+
if (hasImageBlocks(messages) && !supportsGeminiImages(this.model)) {
|
|
98
|
+
yield { type: 'error', message: `image input is not enabled for ${this.model}` }
|
|
99
|
+
return
|
|
100
|
+
}
|
|
95
101
|
|
|
96
|
-
|
|
102
|
+
let payload: GeminiPayload
|
|
103
|
+
try {
|
|
104
|
+
payload = await buildGeminiPayload(messages, this.tools, options)
|
|
105
|
+
} catch (err: unknown) {
|
|
106
|
+
if (err instanceof ImageLoadError) {
|
|
107
|
+
yield { type: 'error', message: err.message }
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
throw err
|
|
111
|
+
}
|
|
97
112
|
const modelName = this.model.replace(/^models\//, '')
|
|
98
113
|
const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(modelName)}:streamGenerateContent?alt=sse`
|
|
99
114
|
|
|
@@ -181,11 +196,11 @@ export class GeminiProvider implements Provider {
|
|
|
181
196
|
}
|
|
182
197
|
}
|
|
183
198
|
|
|
184
|
-
export function buildGeminiPayload(
|
|
199
|
+
export async function buildGeminiPayload(
|
|
185
200
|
messages: Message[],
|
|
186
201
|
tools: GeminiToolDefinition[] = [],
|
|
187
202
|
options: ProviderCompleteOptions = {},
|
|
188
|
-
): GeminiPayload {
|
|
203
|
+
): Promise<GeminiPayload> {
|
|
189
204
|
const systemParts: string[] = []
|
|
190
205
|
const contents: GeminiContent[] = []
|
|
191
206
|
const toolUseNamesById = new Map<string, string>()
|
|
@@ -222,6 +237,10 @@ export function buildGeminiPayload(
|
|
|
222
237
|
for (const block of blocks) {
|
|
223
238
|
if (block.type === 'text') {
|
|
224
239
|
parts.push({ text: block.text })
|
|
240
|
+
} else if (block.type === 'image') {
|
|
241
|
+
const loaded = await loadImageBlock(block)
|
|
242
|
+
if (!loaded.dataBase64 || !loaded.mimeType) throw new Error(`could not load image: ${block.path}`)
|
|
243
|
+
parts.push({ inlineData: { mimeType: loaded.mimeType, data: loaded.dataBase64 } })
|
|
225
244
|
} else if (block.type === 'tool_result') {
|
|
226
245
|
const name = toolUseNamesById.get(block.toolUseId) ?? 'unknown'
|
|
227
246
|
const response: Record<string, unknown> = block.isError
|
|
@@ -258,6 +277,13 @@ function normalizeBlocks(content: Message['content']): MessageContentBlock[] {
|
|
|
258
277
|
})
|
|
259
278
|
}
|
|
260
279
|
|
|
280
|
+
export function supportsGeminiImages(model: string): boolean {
|
|
281
|
+
const normalized = model.toLowerCase()
|
|
282
|
+
return normalized.includes('gemini-1.5')
|
|
283
|
+
|| normalized.includes('gemini-2.0')
|
|
284
|
+
|| normalized.includes('gemini-2.5')
|
|
285
|
+
}
|
|
286
|
+
|
|
261
287
|
function normalizeFinishReason(reason: string, sawToolCall: boolean): DoneStopReason {
|
|
262
288
|
if (sawToolCall) return 'tool_use'
|
|
263
289
|
switch (reason) {
|