ethagent 2.4.0 → 3.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/README.md +7 -4
- package/package.json +2 -1
- package/src/app/FirstRun.tsx +155 -15
- package/src/app/FirstRunTimeline.tsx +4 -0
- package/src/app/input/AppInputProvider.tsx +19 -0
- package/src/app/input/appInputParser.ts +19 -4
- package/src/chat/ChatBottomPane.tsx +3 -1
- package/src/chat/ChatScreen.tsx +7 -1
- package/src/chat/ConversationStack.tsx +25 -19
- package/src/chat/MessageList.tsx +194 -53
- package/src/chat/chatSessionState.ts +1 -1
- package/src/chat/chatTurnOrchestrator.ts +59 -0
- package/src/chat/input/ChatInput.tsx +3 -0
- package/src/chat/input/textCursor.ts +13 -3
- package/src/chat/transcript/TranscriptView.tsx +7 -5
- package/src/chat/transcript/transcriptViewport.ts +88 -17
- package/src/chat/views/PermissionPrompt.tsx +26 -26
- package/src/chat/views/PermissionsView.tsx +18 -12
- package/src/chat/views/RewindView.tsx +3 -1
- package/src/cli/ResetConfirmView.tsx +24 -9
- package/src/identity/continuity/editor.ts +27 -2
- package/src/identity/continuity/envelope.ts +134 -9
- package/src/identity/continuity/publicSkills.ts +54 -1
- package/src/identity/continuity/skills/frontmatter.ts +183 -0
- package/src/identity/continuity/skills/loadSkills.ts +609 -0
- package/src/identity/continuity/skills/publicSkillsSync.ts +32 -0
- package/src/identity/continuity/skills/scaffold.ts +52 -0
- package/src/identity/continuity/skills/types.ts +30 -0
- package/src/identity/continuity/storage/defaults.ts +28 -47
- package/src/identity/continuity/storage/files.ts +1 -0
- package/src/identity/continuity/storage/paths.ts +1 -0
- package/src/identity/continuity/storage/scaffold.ts +25 -23
- package/src/identity/continuity/storage/status.ts +34 -5
- package/src/identity/continuity/storage/types.ts +3 -2
- package/src/identity/continuity/storage.ts +3 -0
- package/src/identity/hub/OperationalRoutes.tsx +79 -5
- package/src/identity/hub/Routes.tsx +5 -3
- package/src/identity/hub/continuity/ContinuityDashboardScreen.tsx +7 -73
- package/src/identity/hub/continuity/RecoveryConfirmScreen.tsx +6 -6
- package/src/identity/hub/continuity/SavePromptScreen.tsx +1 -2
- package/src/identity/hub/continuity/effects.ts +36 -5
- package/src/identity/hub/continuity/skills/DeleteSkillConfirmScreen.tsx +112 -0
- package/src/identity/hub/continuity/skills/NewSkillScreen.tsx +57 -0
- package/src/identity/hub/continuity/skills/NewSkillVisibilityScreen.tsx +52 -0
- package/src/identity/hub/continuity/skills/SkillActionsScreen.tsx +151 -0
- package/src/identity/hub/continuity/skills/SkillsTreeScreen.tsx +181 -0
- package/src/identity/hub/continuity/snapshot.ts +3 -0
- package/src/identity/hub/continuity/state.ts +9 -8
- package/src/identity/hub/continuity/vault.ts +42 -10
- package/src/identity/hub/create/CreateFlow.tsx +1 -1
- package/src/identity/hub/custody/CustodyEditFlow.tsx +3 -3
- package/src/identity/hub/custody/routes.tsx +1 -1
- package/src/identity/hub/ens/EnsEditAdvancedScreens.tsx +0 -1
- package/src/identity/hub/ens/EnsEditMaintenanceScreens.tsx +0 -1
- package/src/identity/hub/identityHubReducer.ts +15 -0
- package/src/identity/hub/profile/EditProfileFlow.tsx +5 -5
- package/src/identity/hub/profile/effects.ts +16 -3
- package/src/identity/hub/restore/RestoreFlow.tsx +43 -6
- package/src/identity/hub/restore/apply.ts +12 -1
- package/src/identity/hub/restore/recovery.ts +14 -4
- package/src/identity/hub/restore/resolve.ts +1 -1
- package/src/identity/hub/restore/useRestoreEffects.ts +4 -6
- package/src/identity/hub/shared/components/DetailsScreen.tsx +4 -1
- package/src/identity/hub/shared/components/IdentitySummary.tsx +118 -54
- package/src/identity/hub/shared/components/MenuScreen.tsx +21 -18
- package/src/identity/hub/shared/components/UnlinkedIdentityScreen.tsx +4 -4
- package/src/identity/hub/shared/components/menuFlagsFromReconciliation.ts +8 -12
- package/src/identity/hub/shared/effects/sync.ts +16 -3
- package/src/identity/hub/shared/model/copy.ts +2 -4
- package/src/identity/hub/transfer/effects.ts +15 -2
- package/src/identity/hub/useIdentityHubContinuity.ts +145 -23
- package/src/identity/hub/useIdentityHubController.ts +5 -1
- package/src/identity/hub/useIdentityHubSideEffects.ts +2 -4
- package/src/identity/wallet/page/copy.ts +43 -43
- package/src/mcp/manager.ts +1 -1
- package/src/models/ModelPicker.tsx +89 -84
- package/src/models/llamacpp.ts +160 -11
- package/src/models/llamacppPreflight.ts +1 -16
- package/src/models/modelPickerOptions.ts +45 -37
- package/src/providers/contracts.ts +1 -0
- package/src/providers/openai-chat.ts +50 -9
- package/src/providers/openai-responses.ts +19 -4
- package/src/runtime/toolExecution.ts +4 -3
- package/src/runtime/turn.ts +61 -30
- package/src/tools/changeDirectoryTool.ts +1 -1
- package/src/tools/contracts.ts +10 -0
- package/src/tools/deleteFileTool.ts +1 -1
- package/src/tools/editTool.ts +1 -1
- package/src/tools/listDirectoryTool.ts +1 -1
- package/src/tools/listSkillFilesTool.ts +77 -0
- package/src/tools/listSkillsTool.ts +68 -0
- package/src/tools/mcpResourceTools.ts +2 -2
- package/src/tools/privateContinuityReadTool.ts +1 -1
- package/src/tools/readSkillTool.ts +107 -0
- package/src/tools/readTool.ts +1 -1
- package/src/tools/registry.ts +6 -0
- package/src/tools/writeFileTool.ts +22 -2
- package/src/ui/Spinner.tsx +1 -1
- package/src/identity/continuity/localBackup.ts +0 -249
- package/src/identity/continuity/zipWriter.ts +0 -95
- package/src/identity/hub/continuity/index.ts +0 -7
- package/src/identity/hub/ens/index.ts +0 -11
- package/src/identity/hub/restore/index.ts +0 -22
|
@@ -7,10 +7,10 @@ import { Surface } from '../ui/Surface.js'
|
|
|
7
7
|
import { ProgressBar } from '../ui/ProgressBar.js'
|
|
8
8
|
import { theme } from '../ui/theme.js'
|
|
9
9
|
import {
|
|
10
|
-
buildLlamaCppRunner,
|
|
11
10
|
DEFAULT_LLAMA_HOST,
|
|
12
11
|
detectLlamaCpp,
|
|
13
12
|
installLlamaCppRunner,
|
|
13
|
+
killRogueLlamaProcesses,
|
|
14
14
|
setLlamaCppServerPath,
|
|
15
15
|
startLlamaCppServer,
|
|
16
16
|
stopLlamaCppServer,
|
|
@@ -80,6 +80,7 @@ type ModelPickerProps = {
|
|
|
80
80
|
currentModel: string
|
|
81
81
|
contextFit?: ModelPickerContextFit | null
|
|
82
82
|
featuredHfRepo?: string
|
|
83
|
+
localOnly?: boolean
|
|
83
84
|
onPick: (selection: ModelPickerSelection) => void
|
|
84
85
|
onCancel: () => void
|
|
85
86
|
}
|
|
@@ -126,12 +127,20 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
|
|
|
126
127
|
currentModel,
|
|
127
128
|
contextFit,
|
|
128
129
|
featuredHfRepo,
|
|
130
|
+
localOnly = false,
|
|
129
131
|
onPick,
|
|
130
132
|
onCancel,
|
|
131
133
|
}) => {
|
|
132
134
|
const [state, setState] = useState<State>({ kind: 'loading' })
|
|
133
135
|
const hfAbortRef = useRef<AbortController | null>(null)
|
|
134
136
|
const oauthServiceRef = useRef<OpenAIOAuthService | null>(null)
|
|
137
|
+
const dismissToList = (data: LoadedData) => () => {
|
|
138
|
+
if (localOnly) {
|
|
139
|
+
onCancel()
|
|
140
|
+
} else {
|
|
141
|
+
setState({ kind: 'list', data })
|
|
142
|
+
}
|
|
143
|
+
}
|
|
135
144
|
|
|
136
145
|
useEffect(() => {
|
|
137
146
|
let cancelled = false
|
|
@@ -193,8 +202,20 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
|
|
|
193
202
|
|
|
194
203
|
if (state.kind === 'loading') {
|
|
195
204
|
return (
|
|
196
|
-
<Surface
|
|
197
|
-
|
|
205
|
+
<Surface
|
|
206
|
+
title={localOnly ? 'Local Model' : (contextFit ? 'Switch to Larger-Context Model' : 'Switch Provider · Model')}
|
|
207
|
+
subtitle="Loading providers and models."
|
|
208
|
+
footer="esc back"
|
|
209
|
+
>
|
|
210
|
+
<Spinner label={localOnly ? 'loading local models...' : 'loading providers...'} />
|
|
211
|
+
<Box marginTop={1}>
|
|
212
|
+
<Select<'cancel'>
|
|
213
|
+
options={[{ value: 'cancel', label: 'Back', hint: 'Return to the previous screen', role: 'utility' }]}
|
|
214
|
+
hintLayout="inline"
|
|
215
|
+
onSubmit={() => onCancel()}
|
|
216
|
+
onCancel={() => onCancel()}
|
|
217
|
+
/>
|
|
218
|
+
</Box>
|
|
198
219
|
</Surface>
|
|
199
220
|
)
|
|
200
221
|
}
|
|
@@ -210,7 +231,7 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
|
|
|
210
231
|
label="Model Link"
|
|
211
232
|
placeholder={LOCAL_MODEL_LINK_HINT}
|
|
212
233
|
onSubmit={value => void inspectHfInput(state, value, setState)}
|
|
213
|
-
onCancel={(
|
|
234
|
+
onCancel={dismissToList(state.data)}
|
|
214
235
|
/>
|
|
215
236
|
{state.error ? <Text color={theme.accentError}>{state.error}</Text> : null}
|
|
216
237
|
</Surface>
|
|
@@ -273,10 +294,12 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
|
|
|
273
294
|
</Box>
|
|
274
295
|
<Select<'download' | 'downloadWithMmproj' | 'pick' | 'cancel'>
|
|
275
296
|
options={[
|
|
297
|
+
{ value: 'download', role: 'section', label: 'Download' },
|
|
276
298
|
...(mmproj ? [{ value: 'downloadWithMmproj' as const, label: `Download Model + Vision Encoder (+${formatBytes(mmproj.sizeBytes)}) · recommended`, disabled: !canDownload }] : []),
|
|
277
299
|
{ value: 'download', label: mmproj ? 'Download Without Vision Encoder' : 'Download This Model', disabled: !canDownload },
|
|
300
|
+
{ value: 'pick', role: 'section', label: 'Navigation' },
|
|
278
301
|
{ value: 'pick', label: 'Pick Another File' },
|
|
279
|
-
{ value: 'cancel', label: 'Cancel' },
|
|
302
|
+
{ value: 'cancel', label: 'Cancel', role: 'utility' },
|
|
280
303
|
]}
|
|
281
304
|
onSubmit={choice => {
|
|
282
305
|
if (choice === 'download') void startHfDownload(state, setState, hfAbortRef, onPick)
|
|
@@ -284,9 +307,9 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
|
|
|
284
307
|
void startHfDownload({ ...state, plan: { ...plan, includeMmproj: true } }, setState, hfAbortRef, onPick)
|
|
285
308
|
}
|
|
286
309
|
else if (choice === 'pick') void inspectHfInput({ kind: 'hfInput', data: state.data }, plan.repoId, setState)
|
|
287
|
-
else
|
|
310
|
+
else dismissToList(state.data)()
|
|
288
311
|
}}
|
|
289
|
-
onCancel={(
|
|
312
|
+
onCancel={dismissToList(state.data)}
|
|
290
313
|
/>
|
|
291
314
|
</Surface>
|
|
292
315
|
)
|
|
@@ -326,9 +349,9 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
|
|
|
326
349
|
onSubmit={choice => {
|
|
327
350
|
if (choice === 'add') void downloadMmprojAndContinue(state, setState, onPick)
|
|
328
351
|
else if (choice === 'skip') void startAndPickHfModel({ ...state.model, mmprojAvailable: false }, state, setState, onPick)
|
|
329
|
-
else
|
|
352
|
+
else dismissToList(state.data)()
|
|
330
353
|
}}
|
|
331
|
-
onCancel={(
|
|
354
|
+
onCancel={dismissToList(state.data)}
|
|
332
355
|
/>
|
|
333
356
|
</Surface>
|
|
334
357
|
)
|
|
@@ -359,9 +382,9 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
|
|
|
359
382
|
onSubmit={choice => {
|
|
360
383
|
if (choice === 'retry') setState({ kind: 'mmprojOffer', data: state.data, model: state.model })
|
|
361
384
|
else if (choice === 'skip') void startAndPickHfModel({ ...state.model, mmprojAvailable: false }, state, setState, onPick)
|
|
362
|
-
else
|
|
385
|
+
else dismissToList(state.data)()
|
|
363
386
|
}}
|
|
364
|
-
onCancel={(
|
|
387
|
+
onCancel={dismissToList(state.data)}
|
|
365
388
|
/>
|
|
366
389
|
</Surface>
|
|
367
390
|
)
|
|
@@ -381,9 +404,9 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
|
|
|
381
404
|
]}
|
|
382
405
|
onSubmit={choice => {
|
|
383
406
|
if (choice === 'use') void startAndPickHfModel(state.model, state, setState, onPick)
|
|
384
|
-
else
|
|
407
|
+
else dismissToList(state.data)()
|
|
385
408
|
}}
|
|
386
|
-
onCancel={(
|
|
409
|
+
onCancel={dismissToList(state.data)}
|
|
387
410
|
/>
|
|
388
411
|
</Surface>
|
|
389
412
|
)
|
|
@@ -399,9 +422,9 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
|
|
|
399
422
|
]}
|
|
400
423
|
onSubmit={choice => {
|
|
401
424
|
if (choice === 'retry') setState({ kind: 'hfInput', data: state.data, error: state.input ? undefined : state.message })
|
|
402
|
-
else
|
|
425
|
+
else dismissToList(state.data)()
|
|
403
426
|
}}
|
|
404
|
-
onCancel={(
|
|
427
|
+
onCancel={dismissToList(state.data)}
|
|
405
428
|
/>
|
|
406
429
|
</Surface>
|
|
407
430
|
)
|
|
@@ -431,7 +454,7 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
|
|
|
431
454
|
const target = targets.find(item => `${item.kind}:${item.id}` === value)
|
|
432
455
|
if (target) setState({ kind: 'localUninstallConfirm', data: state.data, target })
|
|
433
456
|
}}
|
|
434
|
-
onCancel={(
|
|
457
|
+
onCancel={dismissToList(state.data)}
|
|
435
458
|
/>
|
|
436
459
|
)}
|
|
437
460
|
</Surface>
|
|
@@ -477,8 +500,8 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
|
|
|
477
500
|
<Surface title="Local Model Uninstalled" subtitle={state.modelName} footer="enter back to picker · esc close">
|
|
478
501
|
<Select<'back'>
|
|
479
502
|
options={[{ value: 'back', label: 'Back To Picker' }]}
|
|
480
|
-
onSubmit={(
|
|
481
|
-
onCancel={(
|
|
503
|
+
onSubmit={dismissToList(state.data)}
|
|
504
|
+
onCancel={dismissToList(state.data)}
|
|
482
505
|
/>
|
|
483
506
|
</Surface>
|
|
484
507
|
)
|
|
@@ -494,9 +517,9 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
|
|
|
494
517
|
]}
|
|
495
518
|
onSubmit={choice => {
|
|
496
519
|
if (choice === 'retry') void uninstallLocalModel({ kind: 'localUninstallConfirm', data: state.data, target: state.target }, setState)
|
|
497
|
-
else
|
|
520
|
+
else dismissToList(state.data)()
|
|
498
521
|
}}
|
|
499
|
-
onCancel={(
|
|
522
|
+
onCancel={dismissToList(state.data)}
|
|
500
523
|
/>
|
|
501
524
|
</Surface>
|
|
502
525
|
)
|
|
@@ -525,9 +548,9 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
|
|
|
525
548
|
if (choice === 'download') setState({ kind: 'hfInput', data: state.data })
|
|
526
549
|
else if (choice === 'install') void installRunnerAndStart(state, setState, onPick)
|
|
527
550
|
else if (choice === 'path') setState({ kind: 'localRunnerPathEntry', data: state.data, model: state.model, submitting: false })
|
|
528
|
-
else
|
|
551
|
+
else dismissToList(state.data)()
|
|
529
552
|
}}
|
|
530
|
-
onCancel={(
|
|
553
|
+
onCancel={dismissToList(state.data)}
|
|
531
554
|
/>
|
|
532
555
|
</Surface>
|
|
533
556
|
)
|
|
@@ -546,15 +569,20 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
|
|
|
546
569
|
const options = buildRunnerRecoveryOptions(state.result)
|
|
547
570
|
return (
|
|
548
571
|
<Surface title="Runner Setup Needs Attention" subtitle={state.result.message} tone="error" footer="enter select · esc back">
|
|
549
|
-
<Select<'retry' | '
|
|
572
|
+
<Select<'stop-and-retry' | 'path' | 'back'>
|
|
550
573
|
options={options}
|
|
574
|
+
hintLayout="inline"
|
|
551
575
|
onSubmit={choice => {
|
|
552
|
-
if (choice === 'retry')
|
|
553
|
-
|
|
576
|
+
if (choice === 'stop-and-retry') {
|
|
577
|
+
void (async () => {
|
|
578
|
+
await killRogueLlamaProcesses()
|
|
579
|
+
await installRunnerAndStart({ kind: 'localRunnerSetup', data: state.data, model: state.model }, setState, onPick)
|
|
580
|
+
})()
|
|
581
|
+
}
|
|
554
582
|
else if (choice === 'path') setState({ kind: 'localRunnerPathEntry', data: state.data, model: state.model, submitting: false })
|
|
555
|
-
else
|
|
583
|
+
else dismissToList(state.data)()
|
|
556
584
|
}}
|
|
557
|
-
onCancel={(
|
|
585
|
+
onCancel={dismissToList(state.data)}
|
|
558
586
|
/>
|
|
559
587
|
</Surface>
|
|
560
588
|
)
|
|
@@ -604,9 +632,9 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
|
|
|
604
632
|
if (choice === 'retry') void startAndPickHfModel(state.model, { kind: 'hfDone', data: state.data, model: state.model }, setState, onPick)
|
|
605
633
|
else if (choice === 'path') setState({ kind: 'localRunnerPathEntry', data: state.data, model: state.model, submitting: false })
|
|
606
634
|
else if (choice === 'install') void installRunnerAndStart({ kind: 'localRunnerSetup', data: state.data, model: state.model }, setState, onPick)
|
|
607
|
-
else
|
|
635
|
+
else dismissToList(state.data)()
|
|
608
636
|
}}
|
|
609
|
-
onCancel={(
|
|
637
|
+
onCancel={dismissToList(state.data)}
|
|
610
638
|
/>
|
|
611
639
|
</Surface>
|
|
612
640
|
)
|
|
@@ -630,7 +658,7 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
|
|
|
630
658
|
placeholder={providerKeyPlaceholder(provider)}
|
|
631
659
|
isSecret
|
|
632
660
|
onSubmit={(value) => void submitKey(state, value, currentConfig, setState)}
|
|
633
|
-
onCancel={(
|
|
661
|
+
onCancel={dismissToList(state.data)}
|
|
634
662
|
/>
|
|
635
663
|
)}
|
|
636
664
|
{error ? <Text color={theme.accentError}>{error}</Text> : null}
|
|
@@ -652,9 +680,11 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
|
|
|
652
680
|
) : (
|
|
653
681
|
<Select
|
|
654
682
|
options={[
|
|
683
|
+
{ value: 'edit', role: 'section', label: 'Credential' },
|
|
655
684
|
{ value: 'edit', label: 'Replace Stored API Key' },
|
|
656
685
|
{ value: 'delete', label: 'Remove Stored API Key' },
|
|
657
|
-
{ value: 'cancel', label: '
|
|
686
|
+
{ value: 'cancel', role: 'section', label: 'Navigation' },
|
|
687
|
+
{ value: 'cancel', label: 'Back', role: 'utility' },
|
|
658
688
|
]}
|
|
659
689
|
onSubmit={(value) => {
|
|
660
690
|
if (value === 'edit') {
|
|
@@ -667,7 +697,7 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
|
|
|
667
697
|
}
|
|
668
698
|
void deleteKey(state, currentConfig, setState, onPick, currentProvider)
|
|
669
699
|
}}
|
|
670
|
-
onCancel={(
|
|
700
|
+
onCancel={dismissToList(state.data)}
|
|
671
701
|
/>
|
|
672
702
|
)}
|
|
673
703
|
{error ? <Text color={theme.accentError}>{error}</Text> : null}
|
|
@@ -705,7 +735,7 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
|
|
|
705
735
|
}
|
|
706
736
|
void signOutOAuth(state, currentConfig, setState, onPick, currentProvider)
|
|
707
737
|
}}
|
|
708
|
-
onCancel={(
|
|
738
|
+
onCancel={dismissToList(state.data)}
|
|
709
739
|
/>
|
|
710
740
|
)}
|
|
711
741
|
{error ? <Text color={theme.accentError}>{error}</Text> : null}
|
|
@@ -731,9 +761,9 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
|
|
|
731
761
|
onSubmit={choice => {
|
|
732
762
|
if (choice === 'retry') void startOpenAIOAuthFlow(state.data, currentConfig, setState, oauthServiceRef, onPick)
|
|
733
763
|
else if (choice === 'apikey') setState({ kind: 'keyEntry', provider: 'openai', action: 'set', data: state.data, submitting: false })
|
|
734
|
-
else
|
|
764
|
+
else dismissToList(state.data)()
|
|
735
765
|
}}
|
|
736
|
-
onCancel={(
|
|
766
|
+
onCancel={dismissToList(state.data)}
|
|
737
767
|
/>
|
|
738
768
|
</Surface>
|
|
739
769
|
)
|
|
@@ -795,11 +825,12 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
|
|
|
795
825
|
options={options}
|
|
796
826
|
initialIndex={initialIndex === -1 ? 0 : initialIndex}
|
|
797
827
|
maxVisible={12}
|
|
828
|
+
hintLayout="inline"
|
|
798
829
|
onSubmit={(value) => {
|
|
799
830
|
const parsed = parseFullCatalogValue(value)
|
|
800
831
|
if (parsed) onPick({ kind: 'cloud', provider: parsed.provider, model: parsed.model, keyJustSet: false })
|
|
801
832
|
}}
|
|
802
|
-
onCancel={(
|
|
833
|
+
onCancel={dismissToList(state.data)}
|
|
803
834
|
/>
|
|
804
835
|
</Surface>
|
|
805
836
|
)
|
|
@@ -825,9 +856,9 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
|
|
|
825
856
|
onSubmit={choice => {
|
|
826
857
|
if (choice === 'retry') void openLocalCatalog(state.data, setState)
|
|
827
858
|
else if (choice === 'paste') setState({ kind: 'hfInput', data: state.data })
|
|
828
|
-
else
|
|
859
|
+
else dismissToList(state.data)()
|
|
829
860
|
}}
|
|
830
|
-
onCancel={(
|
|
861
|
+
onCancel={dismissToList(state.data)}
|
|
831
862
|
/>
|
|
832
863
|
</Surface>
|
|
833
864
|
)
|
|
@@ -846,28 +877,30 @@ export const ModelPicker: React.FC<ModelPickerProps> = ({
|
|
|
846
877
|
options={options}
|
|
847
878
|
initialIndex={initialIndex === -1 ? 0 : initialIndex}
|
|
848
879
|
maxVisible={12}
|
|
849
|
-
|
|
850
|
-
|
|
880
|
+
hintLayout="inline"
|
|
881
|
+
onSubmit={(value) => handleSubmit(value, state, setState, onPick, onCancel, currentConfig, oauthServiceRef, localOnly)}
|
|
882
|
+
onCancel={dismissToList(state.data)}
|
|
851
883
|
/>
|
|
852
884
|
</Surface>
|
|
853
885
|
)
|
|
854
886
|
}
|
|
855
887
|
|
|
856
888
|
const { data } = state
|
|
857
|
-
const options = buildModelPickerOptions(data, { currentProvider, currentModel, contextFit })
|
|
889
|
+
const options = buildModelPickerOptions(data, { currentProvider, currentModel, contextFit }, { localOnly })
|
|
858
890
|
const initialIndex = localOrCloudOptionIndex(options, currentProvider, currentModel)
|
|
859
891
|
|
|
860
892
|
return (
|
|
861
893
|
<Surface
|
|
862
|
-
title={contextFit ? 'Switch to Larger-Context Model' : 'Switch Provider · Model'}
|
|
863
|
-
subtitle={contextFit ? contextFitSubtitle(contextFit) : 'Downloaded GGUF files · cloud providers'}
|
|
864
|
-
footer=
|
|
894
|
+
title={localOnly ? 'Local Model' : (contextFit ? 'Switch to Larger-Context Model' : 'Switch Provider · Model')}
|
|
895
|
+
subtitle={localOnly ? 'Downloaded GGUF files and curated catalog' : (contextFit ? contextFitSubtitle(contextFit) : 'Downloaded GGUF files · cloud providers')}
|
|
896
|
+
footer={localOnly ? 'enter select · esc back' : 'enter select · esc close · /models lists installed models'}
|
|
865
897
|
>
|
|
866
898
|
<Select
|
|
867
899
|
options={options}
|
|
868
900
|
initialIndex={initialIndex === -1 ? 0 : initialIndex}
|
|
869
901
|
maxVisible={10}
|
|
870
|
-
|
|
902
|
+
hintLayout="inline"
|
|
903
|
+
onSubmit={(value) => handleSubmit(value, state, setState, onPick, onCancel, currentConfig, oauthServiceRef, localOnly)}
|
|
871
904
|
onCancel={onCancel}
|
|
872
905
|
/>
|
|
873
906
|
</Surface>
|
|
@@ -882,6 +915,7 @@ function handleSubmit(
|
|
|
882
915
|
onCancel: () => void,
|
|
883
916
|
currentConfig: EthagentConfig,
|
|
884
917
|
oauthServiceRef: React.MutableRefObject<OpenAIOAuthService | null>,
|
|
918
|
+
localOnly: boolean = false,
|
|
885
919
|
): void {
|
|
886
920
|
if (value.startsWith('hdr:')) return
|
|
887
921
|
if (value === 'cancel') {
|
|
@@ -889,7 +923,8 @@ function handleSubmit(
|
|
|
889
923
|
return
|
|
890
924
|
}
|
|
891
925
|
if (value === 'back' && state.kind === 'localCatalog') {
|
|
892
|
-
|
|
926
|
+
if (localOnly) onCancel()
|
|
927
|
+
else setState({ kind: 'list', data: state.data })
|
|
893
928
|
return
|
|
894
929
|
}
|
|
895
930
|
if (value.startsWith('hf:')) {
|
|
@@ -1304,24 +1339,13 @@ export function buildHfFileOptions(
|
|
|
1304
1339
|
}
|
|
1305
1340
|
|
|
1306
1341
|
function buildRunnerRecoveryOptions(
|
|
1307
|
-
|
|
1308
|
-
): SelectOption<'retry' | '
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
hint: 'Uses git and CMake if installed',
|
|
1315
|
-
})
|
|
1316
|
-
}
|
|
1317
|
-
if (result.recovery.includes('runner-path')) {
|
|
1318
|
-
options.push({ value: 'path', label: 'Use Existing Runner Path' })
|
|
1319
|
-
}
|
|
1320
|
-
if (result.recovery.includes('retry-install')) {
|
|
1321
|
-
options.push({ value: 'retry', label: 'Retry Automatic Install' })
|
|
1322
|
-
}
|
|
1323
|
-
options.push({ value: 'back', label: 'Back To Picker' })
|
|
1324
|
-
return options
|
|
1342
|
+
_result: Extract<LlamaCppInstallResult, { ok: false }>,
|
|
1343
|
+
): SelectOption<'stop-and-retry' | 'path' | 'back'>[] {
|
|
1344
|
+
return [
|
|
1345
|
+
{ value: 'stop-and-retry', label: 'Stop and Retry', hint: 'Stop background runners and try the install again' },
|
|
1346
|
+
{ value: 'path', label: 'Use Existing Runner Path' },
|
|
1347
|
+
{ value: 'back', label: 'Back To Picker' },
|
|
1348
|
+
]
|
|
1325
1349
|
}
|
|
1326
1350
|
|
|
1327
1351
|
function localRunnerStartFailureSubtitle(result: Extract<LlamaCppStartResult, { ok: false }>): string {
|
|
@@ -1338,8 +1362,6 @@ function localRunnerStartFailureSubtitle(result: Extract<LlamaCppStartResult, {
|
|
|
1338
1362
|
return result.message
|
|
1339
1363
|
case 'runner-not-installed':
|
|
1340
1364
|
return 'this machine still needs a local runner'
|
|
1341
|
-
case 'untracked-server':
|
|
1342
|
-
return result.message
|
|
1343
1365
|
}
|
|
1344
1366
|
}
|
|
1345
1367
|
|
|
@@ -1554,16 +1576,7 @@ async function downloadMmprojAndContinue(
|
|
|
1554
1576
|
setState({ kind: 'mmprojError', data: state.data, model: state.model, message: 'projector downloaded but path was not persisted' })
|
|
1555
1577
|
return
|
|
1556
1578
|
}
|
|
1557
|
-
|
|
1558
|
-
if (stopResult && stopResult.ok && stopResult.reason === 'untracked-server') {
|
|
1559
|
-
setState({
|
|
1560
|
-
kind: 'mmprojError',
|
|
1561
|
-
data: state.data,
|
|
1562
|
-
model: updated,
|
|
1563
|
-
message: 'Vision encoder downloaded, but a llama-server is already running and ethagent did not launch it. Quit ethagent, stop the external llama-server (taskkill /F /IM llama-server.exe on Windows, pkill llama-server on macOS or Linux), then reopen ethagent to load the projector.',
|
|
1564
|
-
})
|
|
1565
|
-
return
|
|
1566
|
-
}
|
|
1579
|
+
await stopLlamaCppServer().catch(() => null)
|
|
1567
1580
|
const data = { ...state.data, hfModels: await loadHfPickerModels() }
|
|
1568
1581
|
await startAndPickHfModel(updated, { kind: 'mmprojOffer', data, model: updated }, setState, onPick)
|
|
1569
1582
|
}
|
|
@@ -1617,14 +1630,6 @@ async function installRunnerAndStart(
|
|
|
1617
1630
|
await runRunnerSetup(state, setState, onPick, installLlamaCppRunner)
|
|
1618
1631
|
}
|
|
1619
1632
|
|
|
1620
|
-
async function buildRunnerAndStart(
|
|
1621
|
-
state: Extract<State, { kind: 'localRunnerSetup' }>,
|
|
1622
|
-
setState: (s: State) => void,
|
|
1623
|
-
onPick: (sel: ModelPickerSelection) => void,
|
|
1624
|
-
): Promise<void> {
|
|
1625
|
-
await runRunnerSetup(state, setState, onPick, buildLlamaCppRunner)
|
|
1626
|
-
}
|
|
1627
|
-
|
|
1628
1633
|
async function runRunnerSetup(
|
|
1629
1634
|
state: Extract<State, { kind: 'localRunnerSetup' }>,
|
|
1630
1635
|
setState: (s: State) => void,
|
package/src/models/llamacpp.ts
CHANGED
|
@@ -50,7 +50,6 @@ export type LlamaCppStartFailureCode =
|
|
|
50
50
|
| 'spawn-failed'
|
|
51
51
|
| 'runner-exited'
|
|
52
52
|
| 'readiness-timeout'
|
|
53
|
-
| 'untracked-server'
|
|
54
53
|
|
|
55
54
|
export type LlamaCppStartResult =
|
|
56
55
|
| { ok: true; alreadyRunning: boolean }
|
|
@@ -73,6 +72,9 @@ type LlamaCppStartDeps = {
|
|
|
73
72
|
access?: typeof fs.access
|
|
74
73
|
binaryPath?: string
|
|
75
74
|
spawnImpl?: (command: string, args: readonly string[], options: NonNullable<Parameters<typeof spawn>[2]>) => ReturnType<typeof spawn>
|
|
75
|
+
killRogue?: (host: string) => Promise<KillRogueResult>
|
|
76
|
+
rogueDrainTimeoutMs?: number
|
|
77
|
+
rogueDrainPollMs?: number
|
|
76
78
|
}
|
|
77
79
|
|
|
78
80
|
export type LocalRunnerConfig = {
|
|
@@ -369,16 +371,22 @@ export async function startLlamaCppServer(args: {
|
|
|
369
371
|
deps?: LlamaCppStartDeps
|
|
370
372
|
}): Promise<LlamaCppStartResult> {
|
|
371
373
|
const host = args.host ?? DEFAULT_LLAMA_HOST
|
|
372
|
-
|
|
373
|
-
if (initialStatus.state === 'ready') {
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
374
|
+
let initialStatus = await servedModelStatus(host, args.modelAlias)
|
|
375
|
+
if (initialStatus.state === 'ready' && args.mmprojPath) {
|
|
376
|
+
const pid = await readPidFile()
|
|
377
|
+
if (!pid) {
|
|
378
|
+
await (args.deps?.killRogue ?? killRogueLlamaProcesses)(host).catch(() => null)
|
|
379
|
+
const drained = await waitForHostDown(host, args.deps?.rogueDrainTimeoutMs ?? 6000, args.deps?.rogueDrainPollMs ?? 200)
|
|
380
|
+
if (!drained) {
|
|
381
|
+
return startFailure('different-model-running', {
|
|
382
|
+
servedModels: initialStatus.models,
|
|
383
|
+
detail: 'another process is holding the local model port and could not be stopped automatically',
|
|
379
384
|
})
|
|
380
385
|
}
|
|
386
|
+
initialStatus = await servedModelStatus(host, args.modelAlias)
|
|
381
387
|
}
|
|
388
|
+
}
|
|
389
|
+
if (initialStatus.state === 'ready') {
|
|
382
390
|
return { ok: true, alreadyRunning: true }
|
|
383
391
|
}
|
|
384
392
|
if (initialStatus.state === 'different') {
|
|
@@ -561,6 +569,17 @@ export async function stopLlamaCppServer(args: {
|
|
|
561
569
|
return { ok: true, stopped: true }
|
|
562
570
|
}
|
|
563
571
|
|
|
572
|
+
async function waitForHostDown(host: string, timeoutMs: number, pollMs: number): Promise<boolean> {
|
|
573
|
+
const deadline = Date.now() + timeoutMs
|
|
574
|
+
while (Date.now() < deadline) {
|
|
575
|
+
const { up } = await fetchServedModels(host, 800)
|
|
576
|
+
if (!up) return true
|
|
577
|
+
await new Promise<void>(resolve => setTimeout(resolve, pollMs))
|
|
578
|
+
}
|
|
579
|
+
const { up } = await fetchServedModels(host, 800)
|
|
580
|
+
return !up
|
|
581
|
+
}
|
|
582
|
+
|
|
564
583
|
async function servedModelStatus(host: string, modelAlias: string): Promise<
|
|
565
584
|
| { state: 'not-up'; models: string[] }
|
|
566
585
|
| { state: 'ready'; models: string[] }
|
|
@@ -572,6 +591,136 @@ async function servedModelStatus(host: string, modelAlias: string): Promise<
|
|
|
572
591
|
return { state: 'different', models }
|
|
573
592
|
}
|
|
574
593
|
|
|
594
|
+
export type KillRogueResult = { killed: number; errors: string[] }
|
|
595
|
+
|
|
596
|
+
export async function killRogueLlamaProcesses(host?: string): Promise<KillRogueResult> {
|
|
597
|
+
const result: KillRogueResult = { killed: 0, errors: [] }
|
|
598
|
+
try {
|
|
599
|
+
await stopLlamaCppServer({ timeoutMs: 1500 })
|
|
600
|
+
} catch (err: unknown) {
|
|
601
|
+
result.errors.push(`tracked stop failed: ${(err as Error).message}`)
|
|
602
|
+
}
|
|
603
|
+
const platform = os.platform()
|
|
604
|
+
const portOutcome = await killProcessOnPort(platform, host ?? DEFAULT_LLAMA_HOST)
|
|
605
|
+
result.killed += portOutcome.killed
|
|
606
|
+
if (portOutcome.error) result.errors.push(portOutcome.error)
|
|
607
|
+
const targets = platform === 'win32'
|
|
608
|
+
? ['llama-server.exe', 'llama-cli.exe']
|
|
609
|
+
: ['llama-server', 'llama-cli']
|
|
610
|
+
for (const target of targets) {
|
|
611
|
+
const outcome = await runKillCommand(platform, target)
|
|
612
|
+
result.killed += outcome.killed
|
|
613
|
+
if (outcome.error) result.errors.push(outcome.error)
|
|
614
|
+
}
|
|
615
|
+
await clearPidFile()
|
|
616
|
+
return result
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
export async function killProcessOnPort(
|
|
620
|
+
platform: NodeJS.Platform,
|
|
621
|
+
host: string,
|
|
622
|
+
): Promise<{ killed: number; error?: string }> {
|
|
623
|
+
const port = extractHostPort(host)
|
|
624
|
+
if (!port) return { killed: 0, error: 'no port to scan' }
|
|
625
|
+
const pids = await listListeningPids(platform, port)
|
|
626
|
+
if (pids.length === 0) return { killed: 0 }
|
|
627
|
+
let killed = 0
|
|
628
|
+
const errors: string[] = []
|
|
629
|
+
for (const pid of pids) {
|
|
630
|
+
const outcome = await killByPid(platform, pid)
|
|
631
|
+
if (outcome.killed) killed++
|
|
632
|
+
if (outcome.error) errors.push(outcome.error)
|
|
633
|
+
}
|
|
634
|
+
return errors.length > 0 ? { killed, error: errors.join('; ') } : { killed }
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function extractHostPort(host: string): number | null {
|
|
638
|
+
try {
|
|
639
|
+
const url = new URL(host)
|
|
640
|
+
if (url.port) return Number.parseInt(url.port, 10)
|
|
641
|
+
return url.protocol === 'https:' ? 443 : 80
|
|
642
|
+
} catch {
|
|
643
|
+
return null
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
async function listListeningPids(platform: NodeJS.Platform, port: number): Promise<number[]> {
|
|
648
|
+
if (platform === 'win32') {
|
|
649
|
+
const result = await runCommand('netstat', ['-ano', '-p', 'tcp'], 4000)
|
|
650
|
+
if (!result) return []
|
|
651
|
+
return parseNetstatPids(result.stdout, port)
|
|
652
|
+
}
|
|
653
|
+
const result = await runCommand('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-t'], 4000)
|
|
654
|
+
if (!result || result.code !== 0) return []
|
|
655
|
+
return result.stdout.split(/\r?\n/).map(line => Number.parseInt(line.trim(), 10)).filter(n => Number.isInteger(n) && n > 0)
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
export function parseNetstatPids(output: string, port: number): number[] {
|
|
659
|
+
const pids: number[] = []
|
|
660
|
+
const seen = new Set<number>()
|
|
661
|
+
const portSuffix = `:${port}`
|
|
662
|
+
for (const raw of output.split(/\r?\n/)) {
|
|
663
|
+
const line = raw.trim()
|
|
664
|
+
if (!line || !line.toUpperCase().includes('LISTENING')) continue
|
|
665
|
+
const cols = line.split(/\s+/)
|
|
666
|
+
if (cols.length < 5) continue
|
|
667
|
+
const local = cols[1] ?? ''
|
|
668
|
+
if (!local.endsWith(portSuffix)) continue
|
|
669
|
+
const pid = Number.parseInt(cols[cols.length - 1] ?? '', 10)
|
|
670
|
+
if (!Number.isInteger(pid) || pid <= 0) continue
|
|
671
|
+
if (pid === process.pid) continue
|
|
672
|
+
if (seen.has(pid)) continue
|
|
673
|
+
seen.add(pid)
|
|
674
|
+
pids.push(pid)
|
|
675
|
+
}
|
|
676
|
+
return pids
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
async function killByPid(platform: NodeJS.Platform, pid: number): Promise<{ killed: boolean; error?: string }> {
|
|
680
|
+
return new Promise(resolve => {
|
|
681
|
+
const cmd = platform === 'win32' ? 'taskkill' : 'kill'
|
|
682
|
+
const args = platform === 'win32' ? ['/F', '/T', '/PID', String(pid)] : ['-9', String(pid)]
|
|
683
|
+
const child = spawn(cmd, args, { stdio: 'ignore' })
|
|
684
|
+
child.on('error', err => resolve({ killed: false, error: `${cmd} ${pid}: ${err.message}` }))
|
|
685
|
+
child.on('close', code => {
|
|
686
|
+
if (code === 0) {
|
|
687
|
+
resolve({ killed: true })
|
|
688
|
+
return
|
|
689
|
+
}
|
|
690
|
+
resolve({ killed: false, error: `${cmd} ${pid} exited ${code}` })
|
|
691
|
+
})
|
|
692
|
+
})
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
async function runKillCommand(
|
|
696
|
+
platform: NodeJS.Platform,
|
|
697
|
+
target: string,
|
|
698
|
+
): Promise<{ killed: number; error?: string }> {
|
|
699
|
+
return new Promise(resolve => {
|
|
700
|
+
const cmd = platform === 'win32' ? 'taskkill' : 'pkill'
|
|
701
|
+
const args = platform === 'win32'
|
|
702
|
+
? ['/F', '/T', '/IM', target]
|
|
703
|
+
: ['-f', target]
|
|
704
|
+
const child = spawn(cmd, args, { stdio: 'ignore' })
|
|
705
|
+
child.on('error', err => resolve({ killed: 0, error: `${cmd} ${target}: ${err.message}` }))
|
|
706
|
+
child.on('close', code => {
|
|
707
|
+
if (code === 0) {
|
|
708
|
+
resolve({ killed: 1 })
|
|
709
|
+
return
|
|
710
|
+
}
|
|
711
|
+
if (platform === 'win32' && code === 128) {
|
|
712
|
+
resolve({ killed: 0 })
|
|
713
|
+
return
|
|
714
|
+
}
|
|
715
|
+
if (platform !== 'win32' && code === 1) {
|
|
716
|
+
resolve({ killed: 0 })
|
|
717
|
+
return
|
|
718
|
+
}
|
|
719
|
+
resolve({ killed: 0, error: `${cmd} ${target} exited ${code}` })
|
|
720
|
+
})
|
|
721
|
+
})
|
|
722
|
+
}
|
|
723
|
+
|
|
575
724
|
function startFailure(
|
|
576
725
|
code: LlamaCppStartFailureCode,
|
|
577
726
|
options: { detail?: string; servedModels?: string[] } = {},
|
|
@@ -593,15 +742,15 @@ function startFailureMessage(code: LlamaCppStartFailureCode, servedModels: strin
|
|
|
593
742
|
case 'model-file-missing':
|
|
594
743
|
return detail ? `model file not found: ${detail}` : 'model file was not found'
|
|
595
744
|
case 'different-model-running':
|
|
596
|
-
return
|
|
745
|
+
return servedModels.length > 0
|
|
746
|
+
? `a different local model is already running (${servedModels.join(', ')}); stop it before switching models`
|
|
747
|
+
: detail ?? 'a different local model is already running; stop it before switching models'
|
|
597
748
|
case 'spawn-failed':
|
|
598
749
|
return 'local runner could not be started'
|
|
599
750
|
case 'runner-exited':
|
|
600
751
|
return 'local runner closed before becoming ready'
|
|
601
752
|
case 'readiness-timeout':
|
|
602
753
|
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'
|
|
605
754
|
}
|
|
606
755
|
}
|
|
607
756
|
|