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.
Files changed (168) hide show
  1. package/README.md +11 -0
  2. package/package.json +2 -1
  3. package/src/app/FirstRun.tsx +3 -7
  4. package/src/app/FirstRunTimeline.tsx +1 -1
  5. package/src/chat/ChatBottomPane.tsx +29 -11
  6. package/src/chat/ChatScreen.tsx +169 -38
  7. package/src/chat/ConversationStack.tsx +1 -1
  8. package/src/chat/MessageList.tsx +185 -72
  9. package/src/chat/SessionStatus.tsx +3 -1
  10. package/src/chat/chatScreenUtils.ts +11 -15
  11. package/src/chat/chatSessionState.ts +5 -2
  12. package/src/chat/chatTurnOrchestrator.ts +7 -9
  13. package/src/chat/commands.ts +26 -26
  14. package/src/chat/display/DiffView.tsx +193 -0
  15. package/src/chat/display/SyntaxText.tsx +192 -0
  16. package/src/chat/display/toolCallDisplay.ts +103 -0
  17. package/src/chat/display/toolResultDisplay.ts +19 -0
  18. package/src/chat/{ChatInput.tsx → input/ChatInput.tsx} +61 -25
  19. package/src/chat/input/imageRefs.ts +30 -0
  20. package/src/chat/{TranscriptView.tsx → transcript/TranscriptView.tsx} +24 -50
  21. package/src/chat/{transcriptViewport.ts → transcript/transcriptViewport.ts} +12 -30
  22. package/src/chat/{ContextLimitView.tsx → views/ContextLimitView.tsx} +3 -3
  23. package/src/chat/{ContinuityEditReviewView.tsx → views/ContinuityEditReviewView.tsx} +11 -3
  24. package/src/chat/{CopyPicker.tsx → views/CopyPicker.tsx} +4 -5
  25. package/src/chat/{PermissionPrompt.tsx → views/PermissionPrompt.tsx} +16 -17
  26. package/src/chat/{PermissionsView.tsx → views/PermissionsView.tsx} +6 -6
  27. package/src/chat/{PlanApprovalView.tsx → views/PlanApprovalView.tsx} +4 -4
  28. package/src/chat/{ResumeView.tsx → views/ResumeView.tsx} +50 -41
  29. package/src/chat/views/RewindView.tsx +410 -0
  30. package/src/identity/continuity/privateEdit/diff.ts +2 -78
  31. package/src/identity/hub/OperationalRoutes.tsx +21 -21
  32. package/src/identity/hub/Routes.tsx +13 -13
  33. package/src/identity/hub/{flows/continuity → continuity}/ContinuityDashboardScreen.tsx +9 -9
  34. package/src/identity/hub/{flows/continuity → continuity}/RebackupStorageScreen.tsx +2 -2
  35. package/src/identity/hub/{flows/continuity → continuity}/RecoveryConfirmScreen.tsx +5 -5
  36. package/src/identity/hub/{flows/continuity → continuity}/SavePromptScreen.tsx +5 -5
  37. package/src/identity/hub/{effects/rebackup/runRebackup.ts → continuity/effects.ts} +17 -17
  38. package/src/identity/hub/{effects/rebackup → continuity}/index.ts +1 -1
  39. package/src/identity/hub/{effects/shared → continuity}/snapshot.ts +8 -8
  40. package/src/identity/hub/{effects/rebackup → continuity}/vault.ts +15 -15
  41. package/src/identity/hub/{flows/create → create}/CreateFlow.tsx +13 -13
  42. package/src/identity/hub/{effects/create.ts → create/effects.ts} +4 -4
  43. package/src/identity/hub/{flows/custody → custody}/CustodyEditFlow.tsx +9 -9
  44. package/src/identity/hub/{flows/custody/custodyFlowActions.ts → custody/actions.ts} +6 -6
  45. package/src/identity/hub/{flows/custody/custodyFlowHelpers.ts → custody/helpers.ts} +4 -4
  46. package/src/identity/hub/{effects/vault → custody}/preflight.ts +5 -5
  47. package/src/identity/hub/{flows/custody/custodyFlowRoutes.tsx → custody/routes.tsx} +8 -8
  48. package/src/identity/hub/{flows/custody/custodyEffects.ts → custody/transactions.ts} +9 -9
  49. package/src/identity/hub/{flows/custody/custodyFlowTypes.ts → custody/types.ts} +5 -5
  50. package/src/identity/hub/{flows/custody/custodyFlowEffects.ts → custody/useCustodyEffects.ts} +7 -7
  51. package/src/identity/hub/{flows/custody → custody}/useCustodyFlow.tsx +5 -5
  52. package/src/identity/hub/{flows/ens → ens}/EnsEditAdvancedScreens.tsx +13 -13
  53. package/src/identity/hub/{flows/ens → ens}/EnsEditFlow.tsx +7 -7
  54. package/src/identity/hub/{flows/ens → ens}/EnsEditMaintenanceScreens.tsx +10 -10
  55. package/src/identity/hub/{flows/ens → ens}/EnsEditReviewScreens.tsx +12 -12
  56. package/src/identity/hub/{flows/ens → ens}/EnsEditRunners.tsx +5 -5
  57. package/src/identity/hub/{flows/ens → ens}/EnsEditShared.tsx +10 -10
  58. package/src/identity/hub/{flows/ens → ens}/EnsEditSimpleScreens.tsx +14 -14
  59. package/src/identity/hub/{flows/ens/IdentityHubEnsFlow.tsx → ens/EnsFlow.tsx} +12 -12
  60. package/src/identity/hub/{flows/ens/OperatorWalletsScreen.tsx → ens/EnsOperatorWalletsScreen.tsx} +17 -17
  61. package/src/identity/hub/{advancedEnsValidation.ts → ens/advancedEnsValidation.ts} +2 -2
  62. package/src/identity/hub/{flows/ens/ensEditCopy.ts → ens/editCopy.ts} +3 -3
  63. package/src/identity/hub/{effects/ens/flows.ts → ens/effects.ts} +7 -7
  64. package/src/identity/hub/{effects/ens → ens}/index.ts +1 -1
  65. package/src/identity/hub/{model/ens.ts → ens/state.ts} +1 -1
  66. package/src/identity/hub/{effects/ens → ens}/transactions.ts +239 -239
  67. package/src/identity/hub/{flows/ens/ensEditTypes.ts → ens/types.ts} +7 -7
  68. package/src/identity/hub/identityHubReducer.ts +3 -3
  69. package/src/identity/hub/{flows/profile → profile}/EditProfileFlow.tsx +11 -11
  70. package/src/identity/hub/{effects/publicProfile/runPublicProfileSave.ts → profile/effects.ts} +18 -18
  71. package/src/identity/hub/{model → profile}/identity.ts +3 -3
  72. package/src/identity/hub/{effects/profile/profileState.ts → profile/state.ts} +181 -181
  73. package/src/identity/hub/{flows/restore → restore}/RestoreFlow.tsx +16 -16
  74. package/src/identity/hub/{effects/restore → restore}/apply.ts +10 -10
  75. package/src/identity/hub/{effects/restore → restore}/auth.ts +7 -7
  76. package/src/identity/hub/{effects/restore → restore}/discover.ts +6 -6
  77. package/src/identity/hub/{effects/restore → restore}/envelopes.ts +2 -2
  78. package/src/identity/hub/{effects/restore → restore}/fetch.ts +3 -3
  79. package/src/identity/hub/{effects/restore/shared.ts → restore/helpers.ts} +6 -6
  80. package/src/identity/hub/{effects/restore → restore}/recovery.ts +10 -10
  81. package/src/identity/hub/{effects/restore → restore}/resolve.ts +4 -4
  82. package/src/identity/hub/{effects → restore}/restoreAdmin.ts +1 -1
  83. package/src/identity/hub/{flows/restore/useRestoreFlowEffects.ts → restore/useRestoreEffects.ts} +5 -5
  84. package/src/identity/hub/{flows/settings → settings}/StorageCredentialScreen.tsx +5 -5
  85. package/src/identity/hub/{components → shared/components}/BusyScreen.tsx +4 -4
  86. package/src/identity/hub/{components → shared/components}/DetailsScreen.tsx +4 -4
  87. package/src/identity/hub/{components → shared/components}/ErrorScreen.tsx +4 -4
  88. package/src/identity/hub/{components → shared/components}/FlowTimeline.tsx +1 -1
  89. package/src/identity/hub/{components → shared/components}/IdentitySummary.tsx +8 -8
  90. package/src/identity/hub/{components → shared/components}/MenuScreen.tsx +7 -7
  91. package/src/identity/hub/{components → shared/components}/NetworkScreen.tsx +4 -4
  92. package/src/identity/hub/{components → shared/components}/PinataJwtInput.tsx +4 -4
  93. package/src/identity/hub/{components → shared/components}/UnlinkedIdentityScreen.tsx +5 -5
  94. package/src/identity/hub/{components → shared/components}/WalletApprovalScreen.tsx +6 -6
  95. package/src/identity/hub/{components → shared/components}/menuFlagsFromReconciliation.ts +1 -1
  96. package/src/identity/hub/{effects/shared → shared/effects}/profilePrep.ts +1 -1
  97. package/src/identity/hub/{effects → shared/effects}/receipts.ts +2 -2
  98. package/src/identity/hub/{effects/shared → shared/effects}/sync.ts +4 -4
  99. package/src/identity/hub/{effects → shared/effects}/types.ts +3 -3
  100. package/src/identity/hub/{model → shared/model}/copy.ts +2 -2
  101. package/src/identity/hub/{model → shared/model}/errors.ts +5 -5
  102. package/src/identity/hub/{model → shared/model}/network.ts +3 -3
  103. package/src/identity/hub/{operatorWallets.ts → shared/operatorWallets.ts} +1 -1
  104. package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/hook.ts +1 -1
  105. package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/ownership.ts +2 -2
  106. package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/run.ts +6 -6
  107. package/src/identity/hub/{utils.ts → shared/utils.ts} +5 -5
  108. package/src/identity/hub/{flows/token-transfer/IdentityHubTokenTransferFlow.tsx → transfer/TokenTransferFlow.tsx} +8 -8
  109. package/src/identity/hub/{flows/token-transfer → transfer}/TokenTransferScreens.tsx +14 -14
  110. package/src/identity/hub/{effects/token-transfer/runTokenTransfer.ts → transfer/effects.ts} +16 -16
  111. package/src/identity/hub/{effects/token-transfer → transfer}/progress.ts +1 -1
  112. package/src/identity/hub/useIdentityHubController.ts +11 -11
  113. package/src/identity/hub/useIdentityHubSideEffects.ts +11 -11
  114. package/src/models/ModelPicker.tsx +143 -9
  115. package/src/models/catalog.ts +2 -1
  116. package/src/models/huggingface.ts +180 -2
  117. package/src/models/llamacpp.ts +110 -15
  118. package/src/models/llamacppPreflight.ts +30 -11
  119. package/src/models/modelPickerOptions.ts +16 -15
  120. package/src/models/providerDisplay.ts +16 -0
  121. package/src/providers/anthropic.ts +36 -5
  122. package/src/providers/contracts.ts +9 -1
  123. package/src/providers/errors.ts +6 -4
  124. package/src/providers/gemini.ts +29 -3
  125. package/src/providers/openai-chat.ts +83 -3
  126. package/src/providers/openai-responses-format.ts +29 -8
  127. package/src/providers/openai-responses.ts +22 -7
  128. package/src/providers/registry.ts +1 -0
  129. package/src/runtime/sessionMode.ts +1 -1
  130. package/src/runtime/systemPrompt.ts +3 -1
  131. package/src/runtime/toolExecution.ts +9 -6
  132. package/src/runtime/turn.ts +29 -0
  133. package/src/storage/config.ts +1 -0
  134. package/src/storage/rewind.ts +20 -0
  135. package/src/storage/sessions.ts +16 -3
  136. package/src/tools/bashSafety.ts +7 -3
  137. package/src/tools/bashTool.ts +1 -1
  138. package/src/tools/contracts.ts +3 -0
  139. package/src/tools/deleteFileTool.ts +8 -3
  140. package/src/tools/editTool.ts +10 -5
  141. package/src/tools/fileDiff.ts +261 -0
  142. package/src/tools/privateContinuityEditTool.ts +5 -1
  143. package/src/tools/writeFileTool.ts +8 -3
  144. package/src/ui/Spinner.tsx +39 -5
  145. package/src/ui/TextInput.tsx +2 -2
  146. package/src/ui/theme.ts +19 -0
  147. package/src/utils/clipboard.ts +10 -7
  148. package/src/utils/images.ts +140 -0
  149. package/src/utils/messages.ts +2 -0
  150. package/src/chat/RewindView.tsx +0 -386
  151. package/src/chat/toolResultDisplay.ts +0 -8
  152. package/src/identity/hub/effects/index.ts +0 -73
  153. package/src/identity/hub/effects/publicProfile/index.ts +0 -5
  154. package/src/identity/hub/effects/restore/restoreEffects.ts +0 -22
  155. package/src/identity/hub/effects/token-transfer/index.ts +0 -6
  156. /package/src/chat/{chatInputState.ts → input/chatInputState.ts} +0 -0
  157. /package/src/chat/{chatPaste.ts → input/chatPaste.ts} +0 -0
  158. /package/src/chat/{textCursor.ts → input/textCursor.ts} +0 -0
  159. /package/src/identity/hub/{model/continuity.ts → continuity/state.ts} +0 -0
  160. /package/src/identity/hub/{model/custody.ts → custody/state.ts} +0 -0
  161. /package/src/identity/hub/{effects/restore → restore}/index.ts +0 -0
  162. /package/src/identity/hub/{model → shared/model}/format.ts +0 -0
  163. /package/src/identity/hub/{reconciliation → shared/reconciliation}/agentReconciliation/types.ts +0 -0
  164. /package/src/identity/hub/{reconciliation → shared/reconciliation}/index.ts +0 -0
  165. /package/src/identity/hub/{reconciliation → shared/reconciliation}/useAgentReconciliation.ts +0 -0
  166. /package/src/identity/hub/{reconciliation → shared/reconciliation}/walletSetup.ts +0 -0
  167. /package/src/identity/hub/{txGuard.ts → shared/txGuard.ts} +0 -0
  168. /package/src/identity/hub/{model/transfer.ts → transfer/state.ts} +0 -0
@@ -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') return { ok: true, alreadyRunning: true }
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.deps?.access ?? fs.access)(args.modelPath)
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 === 0 || probe.models.includes(config.model)) {
54
- return { ok: true, alreadyRunning: true }
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
- 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,
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 = Exclude<ProviderId, 'llamacpp'>
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, ['Installed']),
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
- const { system, conversation } = splitMessages(messages)
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
@@ -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
- 'gemini: API key rejected — verify your key at https://aistudio.google.com/app/apikey, then run /key gemini to set it again',
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 ${provider} key (/doctor to verify)`)
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 || `${provider} rate limit exceeded`, { transient: true })
44
+ return new ProviderError(detail || `${name} rate limit exceeded`, { transient: true })
43
45
  }
44
46
  if (response.status >= 500) {
45
- return new ProviderError(detail || `${provider} server error (${response.status})`, { transient: true })
47
+ return new ProviderError(detail || `${name} server error (${response.status})`, { transient: true })
46
48
  }
47
49
  }
48
50
 
@@ -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
- const payload = buildGeminiPayload(messages, this.tools, options)
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) {