okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
8101869
main
1package tui 2 3import ( 4"albedo/cli/internal/daemon" 5"slices" 6"strings" 7"unicode" 8 9"github.com/sahilm/fuzzy" 10) 11 12// mergeModels lists seeds first, then the rest of listed, each id once. A 13// listed entry lends its catalog facts to the seed of the same id. 14func mergeModels (seeds []string ,listed []daemon.Model ) []daemon.Model { 15byID := make (map [string ]daemon.Model ,len (listed )) 16for _ ,m := range listed { 17byID [m .ID ]= m 18 } 19seen := map [string ]bool {"" :true } 20var out []daemon.Model 21add := func (m daemon.Model ) { 22if ! seen [m .ID ] { 23seen [m .ID ]= true 24out = append (out ,m ) 25 } 26 } 27for _ ,id := range seeds { 28m ,ok := byID [id ] 29if ! ok { 30m = daemon.Model {ID :id } 31 } 32add (m ) 33 } 34for _ ,m := range listed { 35add (m ) 36 } 37return out 38} 39 40var searchNoise = strings .NewReplacer (" " ,"" ,"-" ,"" ,"_" ,"" ,"." ,"" ,":" ,"" ,"/" ,"" ) 41 42func searchKey (s string )string {return searchNoise .Replace (strings .ToLower (s )) } 43 44// matchRank orders search matches, compared element by element: an exact id 45// first, then words that start the id or one of its parts, then the fuzzy 46// score. A fuzzy bonus never outweighs a word typed from the start. 47type matchRank [3 ]int 48 49func (a matchRank )compare (b matchRank )int {return slices .Compare (a [:],b [:]) } 50 51// matchModel ranks a model against the search words. Each word must fuzzy 52// match the model id or the profile name; typing profile and id together 53// is an exact match too. 54func matchModel (words []string ,profile ,id string ) (rank matchRank ,hits []int ,ok bool ) { 55rank ,hits ,ok = matchFields (words ,id ,profile ) 56if whole := strings .Join (words ,"" );ok && whole != "" && whole == searchKey (profile + id ) { 57rank [0 ]= 1 58 } 59return rank ,hits ,ok 60} 61 62// searchWords splits a query into the keys its words match by. 63func searchWords (query string ) []string { 64var words []string 65for field := range strings .FieldsSeq (query ) { 66if word := searchKey (field );word != "" { 67words = append (words ,word ) 68 } 69 } 70return words 71} 72 73// matchFields ranks a row's fields against the search words, which every 74// list screen shares: each word must fuzzy match some field. The hits are 75// the characters of the first field to underline. An empty search matches 76// everything equally. 77func matchFields (words []string ,fields ... string ) (rank matchRank ,hits []int ,ok bool ) { 78if whole := strings .Join (words ,"" );whole != "" && slices .ContainsFunc (fields ,func (f string )bool {return whole == searchKey (f ) }) { 79rank [0 ]= 1 80 } 81for _ ,word := range words { 82var best matchRank 83var bestHits []int 84found := fuzzy .Find (word ,fields ) 85for i ,f := range found { 86if r := (matchRank {0 ,startTier (fields [f .Index ],word ),f .Score });i == 0 || r .compare (best )> 0 { 87best ,bestHits = r ,nil 88if f .Index == 0 { 89bestHits = f .MatchedIndexes 90 } 91 } 92 } 93if len (found )== 0 { 94return matchRank {},nil ,false 95 } 96rank [1 ]+= best [1 ] 97rank [2 ]+= best [2 ] 98hits = append (hits ,bestHits ... ) 99 } 100return rank ,hits ,true 101} 102 103// startTier is 2 when word starts s, 1 when it starts a part of s after a 104// separator, else 0. Separators fold as in searchKey, so "gpt5" starts "gpt-5". 105func startTier (s ,word string )int { 106if strings .HasPrefix (searchKey (s ),word ) { 107return 2 108 } 109for i := 1 ;i < len (s );i ++ { 110if strings .ContainsRune (searchSeparators ,rune (s [i - 1 ]))&& strings .HasPrefix (searchKey (s [i :]),word ) { 111return 1 112 } 113 } 114return 0 115} 116 117// refilter rebuilds the rows from the catalogs and the search text. The 118// cursor stays on its row unless reset. 119func (m * ModelPickerModel )refilter (reset bool ) { 120keep := "" 121if r ,ok := m .highlighted ();ok && ! reset { 122keep = r .key () 123 } 124query := strings .TrimSpace (m .search .Value ()) 125words := searchWords (query ) 126 127// Rows rank within their profile, and profiles by their best 128// row. Ties, and an empty search, keep the catalog order. 129type scored struct { 130row modelRow 131rank matchRank 132 } 133var groups [][]scored 134var typed []modelRow 135canType := query != "" && ! strings .ContainsFunc (query ,unicode .IsSpace )&& validateModelID (query )== nil 136for _ ,c := range m .catalogs { 137var group []scored 138for _ ,model := range c .models { 139if rank ,hits ,ok := matchModel (words ,c .name ,model .ID );ok { 140group = append (group ,scored {modelRow {profile :c .name ,model :model ,hits :hits },rank }) 141 } 142 } 143if len (group )> 0 { 144slices .SortStableFunc (group ,func (a ,b scored )int {return b .rank .compare (a .rank ) }) 145groups = append (groups ,group ) 146 } 147// An id has no spaces, so a search of several words offers none. 148if canType && ! slices .ContainsFunc (c .models ,func (model daemon.Model )bool {return model .ID == query }) { 149typed = append (typed ,modelRow {profile :c .name ,model : daemon.Model {ID :query },typed :true }) 150 } 151 } 152slices .SortStableFunc (groups ,func (a ,b []scored )int {return b [0 ].rank .compare (a [0 ].rank ) }) 153m .rows = nil 154for _ ,group := range groups { 155for _ ,s := range group { 156m .rows = append (m .rows ,s .row ) 157 } 158 } 159m .rows = append (m .rows ,typed ... ) 160m .cursor = max (0 ,slices .IndexFunc (m .rows ,func (r modelRow )bool {return r .key ()== keep })) 161}