okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

dawnadd colab extension47735ec

main
13.7 KiB488 linesraw
1package tui
2
3import (
4	"albedo/cli/internal/config"
5	"albedo/cli/internal/daemon"
6	"cmp"
7	"context"
8	"encoding/json"
9	"errors"
10	"fmt"
11	"slices"
12	"strings"
13	"time"
14
15	"charm.land/bubbles/v2/textinput"
16	tea "charm.land/bubbletea/v2"
17)
18
19type LoginStep int
20
21const (
22	StepChoose LoginStep = iota
23	StepName
24	StepBaseURL
25	StepAPIKey
26	StepProject
27	StepLocation
28	StepProtocol
29	StepModels
30	StepModel
31	StepOAuth
32	StepOAuthModels
33	StepRemove
34	StepSaving
35	StepAccountProfile
36	StepOAuthFlow
37	StepOAuthFields
38)
39
40// signInPollInterval is how often the daemon is asked about a running sign-in.
41const signInPollInterval = 300 * time.Millisecond
42
43// removal names something /login can delete: a provider in config.json or an
44// account in the daemon's credential store.
45type removal struct {
46	Kind     string // "provider" | "account"
47	Provider string
48	ID       string // provider name or daemon account id
49	Label    string
50}
51
52type LoginModel struct {
53	readCtx                      context.Context
54	cancelReads                  context.CancelFunc
55	closed                       bool
56	Conn                         *daemon.Connection
57	openBrowser                  func(url string)
58	Removing                     removal
59	Profiles                     config.Profiles
60	Hint                         string
61	Name                         string
62	Provider                     string // sign-in provider in flight, or just finished
63	LoginETag                    string
64	LoginFlow, LoginInstructions string
65	LoginFields                  []daemon.FormField
66	LoginFieldIndex              int
67	LoginValues                  map[string]json.RawMessage
68	SelectedAccount              *daemon.Account
69	LoginID                      string // daemon id of the sign-in in flight
70	SignInURL                    string
71	Status                       string // daemon progress line while a sign-in runs
72	CatalogNote                  string
73	Error                        string
74	Draft                        config.Settings
75	SignIns                      []daemon.SignIn
76	Accounts                     []daemon.Account
77	Catalog                      []string
78	TextInput                    textinput.Model
79	ChoosePicker                 loginPick
80	ProtocolPicker               loginPick
81	ModelPicker                  loginPick
82	confirm
83	Generation int
84	Width      int
85	Height     int
86	Step       LoginStep
87}
88
89// NewLoginModel panics if conn is nil. A nil browser opener disables automatic
90// browser launches.
91func NewLoginModel(conn *daemon.Connection, nameHint string, openBrowser func(string)) LoginModel {
92	if conn == nil {
93		panic("tui.NewLoginModel requires a daemon connection")
94	}
95	profiles := config.Profiles{Providers: map[string]config.Settings{}}
96
97	readCtx, cancelReads := context.WithCancel(context.Background())
98	m := LoginModel{
99		Generation:  nextPageGeneration(),
100		readCtx:     readCtx,
101		cancelReads: cancelReads,
102		Conn:        conn,
103		Step:        StepChoose,
104		Profiles:    profiles,
105		Hint:        strings.TrimSpace(nameHint),
106		Draft:       config.Settings{Extension: "openai", BaseURL: "https://api.openai.com/v1", Protocol: "responses"},
107		TextInput:   newField(),
108		openBrowser: openBrowser,
109	}
110	m.buildChoosePicker()
111	return m
112}
113
114func (m LoginModel) Init() tea.Cmd {
115	gen := m.Generation
116	return func() tea.Msg { return m.listSignIns(m.readCtx, gen) }
117}
118
119func (m LoginModel) Update(msg tea.Msg) (LoginModel, tea.Cmd) {
120	switch msg := msg.(type) {
121	case signInsLoadedMsg:
122		if msg.Gen != m.Generation {
123			return m, nil
124		}
125		m.SignIns = msg.Listed.Logins
126		m.Accounts = msg.Listed.Accounts
127		if msg.Profiles != nil {
128			m.Profiles = *msg.Profiles
129		}
130
131		if msg.Err != nil && m.Error == "" {
132			m.Error = msg.Err.Error()
133		}
134		if m.Step == StepSaving {
135			m.Removing = removal{}
136			m.Step = StepChoose
137			if msg.Err == nil {
138				m.Error = ""
139			}
140			return m, m.backToChoose()
141		}
142		m.buildChoosePicker()
143		if m.Hint != "" {
144			return m, m.applyHint()
145		}
146		if m.Step != StepChoose {
147			return m, nil
148		}
149		if len(m.Profiles.Providers) == 0 && len(m.Accounts) == 0 {
150			m.Step = StepName
151			m.TextInput.Placeholder = ""
152			return m, m.promptInput("", false)
153		}
154		return m, m.ChoosePicker.Init()
155
156	case signInStartedMsg:
157		if msg.Gen != m.Generation || m.Step != StepOAuth {
158			// Stale start: cancel the sign-in this client no longer shows.
159			if msg.ID == m.LoginID {
160				return m, nil
161			}
162			return m, m.cancelSignInCmd(msg.ID)
163		}
164		m.LoginID = msg.ID
165		m.LoginETag = msg.ETag
166		if msg.Err != nil {
167			m.Error = operationError(msg.Err, "", "Sign-in may have started; check your accounts before starting another. Its outcome cannot be confirmed from this response.")
168			if msg.ID == "" {
169				return m, nil
170			}
171			return m, signInPollTickCmd(msg.ID, m.Generation)
172		}
173		m.LoginID = msg.ID
174		m.SignInURL = msg.URL
175		m.Status = ""
176
177		cmds := []tea.Cmd{textinput.Blink}
178		if msg.URL != "" {
179			cmds = append(cmds, m.openBrowserCmd(msg.URL))
180		}
181		cmds = append(cmds, m.pollSignInCmd(msg.ID, m.Generation))
182		return m, tea.Batch(cmds...)
183
184	case signInPollMsg:
185		if msg.Gen != m.Generation || m.Step != StepOAuth || msg.ID != m.LoginID {
186			if msg.ID == m.LoginID {
187				return m, nil
188			}
189			return m, m.cancelSignInCmd(msg.ID)
190		}
191		return m, m.pollSignInCmd(msg.ID, msg.Gen)
192
193	case signInStatusMsg:
194		if msg.Gen != m.Generation || m.Step != StepOAuth || msg.ID != m.LoginID {
195			// A stale result: cancel the sign-in this client no longer shows.
196			if msg.ID == m.LoginID {
197				return m, nil
198			}
199			return m, m.cancelSignInCmd(msg.ID)
200		}
201		if msg.Err != nil {
202			m.Error = msg.Err.Error()
203			if api, ok := errors.AsType[*daemon.APIError](msg.Err); ok && (api.StatusCode == 404 || api.StatusCode == 410) {
204				return m, m.endSignIn()
205			}
206			return m, signInPollTickCmd(msg.ID, m.Generation)
207		}
208		m.LoginETag = msg.ETag
209		m.LoginInstructions = msg.Instructions
210		var browser tea.Cmd
211		if m.SignInURL == "" && msg.URL != "" {
212			m.SignInURL = msg.URL
213			browser = m.openBrowserCmd(msg.URL)
214		}
215		m.Status = msg.Message
216		switch msg.State {
217		case "complete":
218			m.LoginID = ""
219			for _, account := range msg.Accounts {
220				if i := slices.IndexFunc(m.Accounts, func(a daemon.Account) bool { return a.ID == account.ID }); i >= 0 {
221					m.Accounts[i] = account
222				} else {
223					m.Accounts = append(m.Accounts, account)
224				}
225			}
226			if login, ok := m.signInFor(m.Provider); (ok && login.Kind == "service") || (len(msg.Accounts) > 0 && msg.Accounts[0].Kind == "service") {
227				label := m.Provider
228				if ok && login.Label != "" {
229					label = login.Label
230				}
231				m.Status = fmt.Sprintf("Signed in to %s!", label)
232				m.Error = ""
233				return m, m.backToChoose()
234			}
235			if len(msg.Accounts) > 0 {
236				m.Draft.AccountID = &msg.Accounts[0].ID
237			}
238			m.Step = StepOAuthModels
239			m.Generation = nextPageGeneration()
240			return m, m.fetchCatalogCmd(m.Provider, "", m.Generation)
241		case "failed", "cancelled", "expired":
242			m.LoginID = ""
243			m.Error = msg.Message
244			return m, nil
245		default:
246			return m, tea.Batch(browser, signInPollTickCmd(msg.ID, m.Generation))
247		}
248
249	case loginModelsLoadedMsg:
250		if msg.Gen != m.Generation || (m.Step != StepModels && m.Step != StepOAuthModels) {
251			return m, nil
252		}
253		m.Catalog = msg.Models
254		if m.Catalog == nil {
255			m.Catalog = []string{}
256		}
257		m.CatalogNote = msg.Note
258		m.buildModelPicker()
259		return m, m.ModelPicker.Init()
260
261	case providerSavedMsg:
262		if msg.Gen != m.Generation || m.closed {
263			return m, nil
264		}
265		if msg.Err != nil {
266			m.Error = msg.Err.Error()
267			if m.Name == msg.Name && m.Draft.Model != "" {
268				m.Step = StepModel
269				return m, m.promptInput(m.Draft.Model, false)
270			}
271			return m, m.backToChoose()
272		}
273		return m, func() tea.Msg {
274			return LoginDoneMsg{Gen: msg.Gen, Name: msg.Name, Settings: msg.Settings}
275		}
276
277	case tea.KeyPressMsg:
278		key := msg.String()
279		switch {
280		case m.Step == StepRemove:
281			return m, m.confirmKey(msg)
282		case m.Step == StepChoose && key == "ctrl+d":
283			if item, ok := m.ChoosePicker.Highlighted(); ok {
284				if target, ok := m.removalFor(item.ID); ok {
285					return m, m.confirmRemoval(target)
286				}
287			}
288			return m, nil
289		}
290		if key == "esc" || key == "ctrl+c" || key == "ctrl+d" {
291			pickerStep := m.Step == StepChoose || m.Step == StepAccountProfile || m.Step == StepOAuthFlow || m.Step == StepProtocol || m.Step == StepModels || m.Step == StepOAuthModels
292			if !pickerStep || key == "ctrl+d" {
293				cancel := m.Close()
294				m.LoginValues = nil
295				m.LoginFields = nil
296				m.TextInput.SetValue("")
297				m.Generation = nextPageGeneration()
298				return m, tea.Batch(cancel, func() tea.Msg { return LoginCancelMsg{} })
299			}
300		}
301
302	case PickerSelectMsg:
303		switch m.Step {
304		case StepOAuthFlow:
305			m.LoginFlow = msg.ID
306			return m, m.nextLoginField()
307		case StepOAuthFields:
308			return m, m.acceptLoginField(msg.ID)
309		case StepAccountProfile:
310			if m.SelectedAccount != nil {
311				m.Name = msg.ID
312				m.Step = StepSaving
313				return m, m.selectAccountCmd(*m.SelectedAccount)
314			}
315		case StepChoose:
316			if p, ok := customProviderByID(msg.ID); ok {
317				return m, m.startCustomProvider(p)
318			}
319			if provider, ok := strings.CutPrefix(msg.ID, "signin:"); ok {
320				if login, ok := m.signInFor(provider); ok {
321					return m, m.startSignIn(login)
322				}
323			}
324			// Choosing an account selects it; d removes it.
325			if account, ok := m.accountAt(msg.ID); ok {
326				return m, m.chooseAccount(account)
327			}
328			if name, ok := strings.CutPrefix(msg.ID, "use:"); ok {
329				if s, ok := m.Profiles.Providers[name]; ok {
330					return m, m.useProfile(name, s)
331				}
332			}
333
334		case StepProtocol:
335			m.Draft.Protocol = msg.ID
336			return m, m.advanceToModels()
337
338		case StepModels, StepOAuthModels:
339			if msg.ID == "manual" {
340				m.Step = StepModel
341				m.TextInput.Placeholder = ""
342				return m, m.promptInput("", false)
343			}
344			if modelName, ok := strings.CutPrefix(msg.ID, "model:"); ok {
345				m.Draft.Model = modelName
346				m.Step = StepSaving
347				return m, m.saveProviderCmd(m.Name, m.Draft)
348			}
349		}
350
351	case PickerCancelMsg:
352		switch m.Step {
353		case StepOAuthFlow, StepOAuthFields:
354			return m, m.backToChoose()
355		case StepChoose, StepOAuthModels:
356			return m, func() tea.Msg { return LoginCancelMsg{} }
357		case StepProtocol:
358			m.Step = StepAPIKey
359			return m, m.promptInput("", true)
360		case StepModels:
361			m.Generation = nextPageGeneration()
362			if p, _ := m.custom(); p.Scoped {
363				return m, m.askLocation()
364			}
365			if m.isFixedProtocol() {
366				m.Step = StepAPIKey
367				return m, m.promptInput(m.Draft.APIKey, true)
368			}
369			m.Step = StepBaseURL
370			return m, m.promptInput(m.Draft.BaseURL, false)
371		}
372	}
373
374	// A picker step routes everything to its picker; the rest to the input.
375	if m.Step == StepOAuthFields && m.LoginFieldIndex < len(m.LoginFields) {
376		field := m.LoginFields[m.LoginFieldIndex]
377		if field.Type == "choice" || field.Type == "boolean" {
378			return m, m.pickUpdate(msg)
379		}
380	}
381
382	switch m.Step {
383	case StepChoose, StepAccountProfile, StepOAuthFlow, StepProtocol, StepModels, StepOAuthModels:
384		return m, m.pickUpdate(msg)
385	}
386
387	if key, ok := msg.(tea.KeyPressMsg); ok && key.String() == "enter" {
388		switch m.Step {
389		case StepName:
390			m.TextInput.SetCursor(0)
391			name, err := config.ValidateProviderName(strings.TrimSpace(m.TextInput.Value()))
392			if err != nil {
393				return m.fail(err.Error())
394			}
395			m.Name = name
396			m.Error = ""
397
398			// A name that is a sign-in provider — or a saved profile for one —
399			// signs in instead of asking for an api key, unless the profile
400			// keeps a key or this is that extension's api-key flow.
401			flow := m.Draft.Extension
402			extension := name
403			if saved, known := m.Profiles.Providers[name]; known {
404				extension = saved.Extension
405				m.Draft = saved
406			}
407			if login, ok := m.signInFor(extension); ok && !hasKey(m.Draft) && extension != flow {
408				return m, m.startSignIn(login)
409			}
410			return m, m.askEndpoint()
411
412		case StepBaseURL:
413			m.TextInput.SetCursor(0)
414			val := cmp.Or(strings.TrimSpace(m.TextInput.Value()), m.Draft.BaseURL)
415			endpoint := ""
416			if p, _ := m.custom(); val != "" || !p.Optional {
417				var err error
418				if endpoint, err = config.ValidateEndpoint(val); err != nil {
419					return m.fail(err.Error())
420				}
421			}
422			if endpoint != m.Draft.BaseURL {
423				m.Draft.APIKey, m.Draft.HasKey = "", false
424			}
425			m.Draft.BaseURL = endpoint
426			m.Error = ""
427			return m, m.askAPIKey()
428
429		case StepAPIKey:
430			m.TextInput.SetCursor(0)
431			switch val := cmp.Or(m.TextInput.Value(), m.Draft.APIKey); {
432			case val == "" && m.Draft.HasKey:
433				// Blank keeps the key the daemon holds.
434			case val == "" && m.optionalKey():
435			case val == "" || strings.ContainsFunc(val, func(r rune) bool { return r <= 0x20 || r == 0x7f }):
436				return m.fail("Enter an API key without spaces or control characters.")
437			default:
438				m.Draft.APIKey = val
439			}
440			m.Error = ""
441			if m.isFixedProtocol() {
442				return m, m.advanceToModels()
443			}
444			m.Step = StepProtocol
445			m.buildProtocolPicker()
446			return m, m.ProtocolPicker.Init()
447
448		case StepProject:
449			m.TextInput.SetCursor(0)
450			m.Draft.Project = optionalText(strings.TrimSpace(m.TextInput.Value()))
451			m.Error = ""
452			return m, m.askLocation()
453
454		case StepLocation:
455			m.TextInput.SetCursor(0)
456			m.Draft.Location = optionalText(strings.TrimSpace(m.TextInput.Value()))
457			m.Error = ""
458			return m, m.advanceToModels()
459
460		case StepOAuthFields:
461			return m, m.acceptLoginField(m.TextInput.Value())
462		case StepOAuth:
463			val := strings.TrimSpace(m.TextInput.Value())
464			if val != "" && m.LoginID != "" {
465				m.Status = "exchanging authorization code…"
466				m.Error = ""
467				m.Generation = nextPageGeneration()
468				m.TextInput.SetValue("")
469				return m, m.inputSignInCmd(m.LoginID, val, m.Generation)
470			}
471
472		case StepModel:
473			m.TextInput.SetCursor(0)
474			val := strings.TrimSpace(m.TextInput.Value())
475			if err := validateModelID(val); err != nil {
476				return m.fail(err.Error())
477			}
478			m.Draft.Model = val
479			m.Error = ""
480			m.Step = StepSaving
481			return m, m.saveProviderCmd(m.Name, m.Draft)
482		}
483	}
484
485	var cmd tea.Cmd
486	m.TextInput, cmd = m.TextInput.Update(msg)
487	return m, cmd
488}