char-slop/berry
multipurpose self-modifiable agentic harness
git clone https://git.t4t.associates/char-slop/berry
2cf0e3c
main
1import { Signal } from "@char/aftercare" ; 2import { api , watch } from "./client.ts" ; 3import type { ExtensionInfo , Shared } from "./conversation.tsx" ; 4import { computed , keyed , Scope , swap } from "./ui.ts" ; 5import { notificationsAvailable , notificationsOn , setNotifications } from "./notifications.ts" ; 6 7export type ModelRef = { provider :string ; modelId :string }; 8export type ModelInfo = { 9provider :string ; 10id :string ; 11name :string ; 12input :( "text" | "image" )[]; 13reasoning :boolean ; 14thinkingLevels :string []; 15contextWindow :number ; 16cost :{ input :number ; output :number }; 17}; 18 19export const modelKey = ( model :ModelRef | ModelInfo ) => 20` ${ model . provider } / ${ "modelId" in model ? model . modelId : model . id } ` ; 21 22const tokens = ( n :number ) => ( n >= 1_000_000 ?` ${ n / 1_000_000 } M` :` ${ Math . round ( n / 1000 )} k` ); 23const price = ( n :number ) => `$ ${ Number ( n . toFixed ( 2 ))} ` ; 24 25/** A choice among the enabled models, by provider. A model since disabled stays, marked, while it is chosen. */ 26export function modelSelect ( 27scope :Scope , 28models :Signal < readonly ModelInfo []>, 29enabled :Signal < readonly ModelRef []>, 30current :Signal < string >, 31choose :( model :ModelRef ) => void , 32) :HTMLSelectElement { 33const select = < select class = "model" aria - label = "Model" / > as HTMLSelectElement ; 34const render = () => { 35const known = new Map ( models . get (). map (( model ) => [ modelKey ( model ), model ])); 36const keys = enabled . get (). map ( modelKey ); 37const chosen = current . get (); 38if ( chosen !== "" && ! keys . includes ( chosen )) keys . unshift ( chosen ); 39const names = keys . map (( key ) => known . get ( key )?. name ?? key . slice ( key . indexOf ( "/" ) + 1 )); 40// in the order settings give them; a provider shows only where it tells two of the same name apart 41select . replaceChildren ( 42 ...( chosen === "" ?[< option value = "" disabled selected > pick a model </ option >] :[]), 43 ...keys . map (( key , index ) => { 44const name = names [ index ]; 45const shared = names . indexOf ( name ) !== names . lastIndexOf ( name ); 46const marks = [ 47! enabled . get (). some (( m ) => modelKey ( m ) === key ) && "not enabled" , 48! known . has ( key ) && "logged out" , 49]. filter ( Boolean ); 50return ( 51< option value = { key }> 52{ shared ?` ${ name } · ${ key . slice ( 0 , key . indexOf ( "/" ))} ` :name } 53{ marks . length > 0 ?` ( ${ marks . join ( ", " )} )` :"" } 54</ option > 55); 56}), 57); 58select . value = chosen ; 59}; 60for ( const source of [ models , enabled , current ]) scope . add ( source . subscribe ( render ). unsubscribe ); 61render (); 62select . addEventListener ( "change" , () => { 63const slash = select . value . indexOf ( "/" ); 64choose ({ provider :select . value . slice ( 0 , slash ), modelId :select . value . slice ( slash + 1 ) }); 65}); 66return select ; 67} 68 69const save = ( models :readonly ModelRef [ ]) = > 70void api ( "PATCH" , "/api/settings" , { 71models : models . map (({ provider , modelId }) => ({ provider , modelId })), 72}) 73. catch ( console . error ); 74 75const refOf = ( model: ModelInfo ) : ModelRef => ({ provider : model . provider , modelId : model . id }); 76 77/** A model row's name, id, and what it offers; a logged-out model has only its key. */ 78function describe ( model: ModelInfo | undefined , key: string ) : Node [] { 79if ( model === undefined ) { 80return [ 81< span class = "model-name" >{ key . slice ( key . indexOf ( "/" ) + 1 )}</ span >, 82< code >{ key }</ code >, 83< span class = "model-meta" > 84< span > logged out </ span > 85< / span>, 86]; 87} 88return [ 89< span class = "model-name" >{ model . name }</ span >, 90< code >{ model . id }</ code >, 91< span class = "model-meta" > 92< span >{ tokens ( model . contextWindow )}</ span > 93< span >{ price ( model . cost . input )} / {price(model.cost.output)}< / span > 94< / span > , 95]; 96} 97 98/** The enabled models in the order conversations offer them; dragged, or moved with alt+↑/↓, to reorder. */ 99function enabledList ( 100scope : Scope , 101models : Signal < readonly ModelInfo []>, 102enabled : Signal < readonly ModelRef []>, 103) { 104const list = < ol class = "enabled-list" />; 105// the row a keyboard move should keep focus on once the new order comes back 106let refocus : string | null = null ; 107const move = ( from : number , to : number ) => { 108const order = [ ... enabled . get ()]; 109if ( to < 0 || to >= order . length || to === from ) return ; 110const [ moved ] = order . splice ( from , 1 ); 111order . splice ( to , 0 , moved ); 112save ( order ); 113}; 114const changed = new Signal ( 0 ); 115for ( const source of [ models , enabled ]) { 116scope . add ( source . subscribe (() => changed . set ( changed . get () + 1 )). unsubscribe ); 117} 118swap ( scope , list , changed , () => { 119const known = new Map ( models . get (). map (( model ) => [ modelKey ( model ), model ])); 120const rows = enabled . get (). map (( ref , index ) => { 121const key = modelKey ( ref ); 122const row = ( 123< li class = "model-row" tabIndex = { 0 } dataset = {{ key }}> 124< span class = "grip" title = "drag to reorder" /> 125< input 126type = "checkbox" 127checked 128_onchange = {() => save ( enabled . get (). filter (( m ) => modelKey ( m ) !== key ))} 129/> 130{ describe ( known . get ( key ), key )} 131</ li > 132) as HTMLLIElement ; 133// pointer-driven, as chordpad's track reordering: any pointer, one line where the row will land 134row . addEventListener ( "pointerdown" , ( event ) => { 135if ( event . button !== 0 || ( event . target as Element ). closest ( "input" )) return ; 136// no text selection while dragging; the row still takes focus, for alt+↑/↓ 137event . preventDefault (); 138row . focus (); 139row . setPointerCapture ( event . pointerId ); 140const all = [ ...list . children ] as HTMLElement []; 141let slot = index ; 142const mark = () => 143all . forEach (( other , i ) => { 144const lands = slot !== index && slot !== index + 1 ; 145other . classList . toggle ( "drop-before" , lands && i === slot ); 146other . classList . toggle ( 147"drop-after" , 148lands && slot === all . length && i === all . length - 1 , 149); 150}); 151const moved = ( e: PointerEvent ) => { 152// the slot is how many rows have their middle above the pointer 153slot = all . filter (( other ) => { 154const box = other . getBoundingClientRect (); 155return box . top + box . height / 2 < e . clientY ; 156}). length ; 157mark (); 158}; 159const done = ( e: PointerEvent ) => { 160row . removeEventListener ( "pointermove" , moved ); 161row . removeEventListener ( "pointerup" , done ); 162row . removeEventListener ( "pointercancel" , done ); 163row . classList . remove ( "dragging" ); 164document . body . classList . remove ( "reordering" ); 165slot = e . type === "pointercancel" ? index : slot ; 166mark (); 167move ( index , slot > index ? slot - 1 : slot ); 168}; 169row . classList . add ( "dragging" ); 170document . body . classList . add ( "reordering" ); 171row . addEventListener ( "pointermove" , moved ); 172row . addEventListener ( "pointerup" , done ); 173row . addEventListener ( "pointercancel" , done ); 174}); 175row . addEventListener ( "keydown" , ( event ) => { 176if ( ! event . altKey || ( event . key !== "ArrowUp" && event . key !== "ArrowDown" )) return ; 177event . preventDefault (); 178refocus = key ; 179move ( index , index + ( event . key === "ArrowUp" ? - 1 : 1 )); 180}); 181return row ; 182}); 183queueMicrotask (() => { 184if ( refocus === null ) return ; 185rows . find (( row ) => row . dataset . key === refocus )?. focus (); 186refocus = null ; 187}); 188return rows ; 189}); 190return list ; 191} 192 193type ProviderInfo = { 194id: string ; 195name: string ; 196apiKey: { name: string } | null ; 197oauth: { name: string ; label: string | null ; subscription: boolean } | null ; 198/** How it is configured, if it is. */ 199auth: { type: "api_key" | "oauth" ; source?: string } | null ; 200/** Whether berry holds its login, and so can log it out. */ 201stored: boolean ; 202}; 203 204type LoginState = { 205status: "running" | "done" | "failed" | "cancelled" ; 206events: ( 207| { type : "info" ; message: string ; links?: { url : string ; label?: string }[ ] } 208| { type : "auth_url" ; url: string ; instructions?: string } 209| { type : "device_code" ; userCode: string ; verificationUri: string } 210| { type : "progress" ; message: string } 211)[]; 212prompt: 213| { id : number ; type : "text" | "secret" | "manual_code" ; message: string ; placeholder?: string } 214| { 215id : number ; 216type: "select" ; 217message: string ; 218options: { id : string ; label: string ; description?: string }[]; 219} 220| null ; 221error: string | null ; 222}; 223 224const link = ( url: string , label?: string ) => ( 225< a href = { url } target = "_blank" rel = "noopener noreferrer" >{ label ?? url }</ a > 226); 227 228/** A login as it runs on the server: what it says, what it asks, and how it ended. */ 229function loginDialog ( provider: ProviderInfo , type: "api_key" | "oauth" , settled: () => void ) { 230const scope = new Scope (); 231const dialog = < dialog class = "dialog" / > as HTMLDialogElement ; 232const state = new Signal < LoginState | null >( null ); 233let id: string | null = null ; 234const running = () => state . get ()?. status === "running" || state . get () === null ; 235const cancel = () => id && api ( "POST" , `/api/logins/ ${ id } /cancel` ). catch ( console . error ); 236const close = () => { 237scope . dispose (); 238dialog . close (); 239dialog . remove (); 240settled (); 241}; 242// escape cancels a running login, and closes one that has ended 243dialog . addEventListener ( "cancel" , ( event ) => { 244event . preventDefault (); 245if ( running ()) void cancel (); 246else close (); 247}); 248 249// each part follows its own slice, so an event arriving mid-answer leaves the question as typed 250const events = < div class = "login-events" / >; 251swap ( 252scope , 253events , 254state . derive (( login ) => login ?. events ?? null ), 255( list ) => 256list === null ? < p class = "desc" > starting… < / p> : list.map((event) => { 257if ( event . type === "auth_url" ) { 258return ( 259< p > 260{ event . instructions ?? "sign in on this page:" }{ " " } 261{ link ( event . url , "open the sign-in page" )} 262</ p > 263); 264} 265if ( event . type === "device_code" ) { 266return ( 267< p > 268enter < code class =" user-code " >{ event . userCode }</ code > at { link ( event . verificationUri )} 269</ p > 270); 271} 272if ( event . type === "progress" ) return < p class = "desc" >{ event . message }< /p>; 273return < p >{ event . message } {( event . links ?? []). map (( l ) => link ( l . url , l . label ))}</ p >; 274}), 275); 276 277const question = < div />; 278swap ( scope , question , state . derive (( login ) => login ?. prompt ?? null ), ( prompt ) => { 279if ( prompt === null ) return null ; 280if ( prompt . type === "select" ) { 281return ( 282< div class = "question" > 283< p >{ prompt . message }</ p > 284{ prompt . options . map (( option ) => ( 285< button 286type = "button" 287class = "option" 288_onclick = {() => answer ( prompt . id , option . id )} 289> 290< b >{ option . label }</ b > 291{ option . description ? < span >{ option . description }</ span > : undefined } 292</ button > 293))} 294</ div > 295); 296} 297const input = ( 298// masked by css rather than type="password": a provider's key is not this site's password, and a 299// password field's form would have the browser offer to save it 300< input 301type = "text" 302class = { prompt . type === "secret" ? "secret" : "" } 303placeholder = { prompt . placeholder ?? "" } 304autocomplete = "off" 305spellcheck = { false } 306/> 307) as HTMLInputElement ; 308queueMicrotask (() => input . focus ()); 309return ( 310< form 311class = "question" 312_onsubmit = {( event ) => { 313event . preventDefault (); 314answer ( prompt . id , input . value ); 315}} 316> 317< label >{ prompt . message }</ label > 318{ input } 319< button type = "submit" class = "primary" > continue </ button > 320< / form > 321); 322}); 323 324const outcome = < div />; 325swap ( 326scope , 327outcome , 328state . derive (( login ) => login ?? null ), 329( login ) => 330login ?. status === "done" 331? < p class = "ok" > logged in < / p > 332: login?.status === "failed" 333? < p class = " error " >{ login . error ?? "the login failed" }</ p > 334: login?.status === "cancelled" 335? < p class = " desc " > cancelled </ p > 336: null, 337); 338 339const answer = (prompt: number, value: string) = > 340void api ( "POST" , `/api/logins/ ${ id } /answer` , { prompt , value }). catch ( console . error ); 341 342const actions = < div class = "dialog-actions" / >; 343swap ( 344scope , 345actions , 346state . derive (() => running ()) , 347( busy ) => 348busy 349? < button type = "button" _onclick = {() => void cancel ()}> cancel </ button > 350: < button type = "button" class = "primary" _onclick = { close }> close < /button>, 351); 352 353dialog . append ( 354< h2 > log in to { provider . name }</ h2 >, 355< p class = "desc" >{ type === "oauth" ? provider . oauth ! . name : provider . apiKey ! . name }</ p >, 356events , 357question , 358outcome , 359actions , 360); 361document . body . append ( dialog ); 362dialog . showModal (); 363 364api <{ login : string }>( "POST" , `/api/providers/ ${ provider . id } /login` , { type }) 365. then (({ login }) => { 366id = login ; 367scope . add ( watch < LoginState >( `login: ${ login } ` , ( value ) => state . set ( value ))); 368}) 369. catch (( error ) => 370state . set ({ 371status : "failed" , 372events : [], 373prompt : null , 374error : error instanceof Error ? error . message : String ( error ), 375}) 376); 377} 378 379/** The providers: those configured first, with how; the rest folded away, with their logins. */ 380function providerList ( scope : Scope , shared : Shared ) : Element { 381const providers = new Signal < readonly ProviderInfo []>([]); 382const refresh = () => { 383api < ProviderInfo []>( "GET" , "/api/providers" ). then (( list ) => providers . set ( list )). catch ( 384console . error , 385); 386shared . refreshModels (); 387}; 388refresh (); 389 390const how = ( provider : ProviderInfo ) => 391provider . auth ?. type === "oauth" 392? "signed in" 393: provider . stored 394? "api key" 395: provider . auth ?. source ?? "configured" ; 396const logins = ( provider : ProviderInfo ) => [ 397... ( provider . oauth 398? [ 399< button 400type = "button" 401title = { provider . oauth . name } 402_onclick = {() => loginDialog ( provider , "oauth" , refresh )} 403> 404{ provider . oauth . label ?? ( provider . oauth . subscription ? "subscription" : "sign in" )} 405</ button >, 406] 407: []), 408... ( provider . apiKey 409? [ 410< button 411type = "button" 412title = { provider . apiKey . name } 413_onclick = {() => loginDialog ( provider , "api_key" , refresh )} 414> 415api key 416</ button >, 417] 418: []), 419]; 420const row = ( provider : ProviderInfo ) => ( 421< div class = "provider-row" > 422< span class = "model-name" >{ provider . name }</ span > 423< code >{ provider . id }</ code > 424< span class = "model-meta" > 425{ provider . auth ? < span class = "ok" >{ how ( provider )}< /span> : undefined} 426</ span > 427< span class = "actions" > 428{ provider . stored 429? ( 430< button 431type = "button" 432class = "danger" 433_onclick = {() => 434api ( "POST" , `/api/providers/ ${ provider . id } /logout` ). then ( refresh ). catch ( 435console . error , 436)} 437> 438log out 439< /button> 440) 441: provider . auth 442? undefined 443: logins ( provider )} 444</ span > 445< / div > 446); 447 448const list = < div class =" provider-list " / > ; 449swap(scope, list, providers, (all) = > { 450const configured = all . filter (( provider ) => provider . auth !== null ); 451const others = all . filter (( provider ) => provider . auth === null ); 452return [ 453... configured . map ( row ), 454< details class = "provider" > 455< summary > 456< span class = "provider-name" > other providers < / span> 457< span class = "count" >{ String ( others . length )}< /span> 458</ summary > 459{ others . map ( row )} 460</ details >, 461]; 462}); 463return list ; 464} 465 466/** The extensions on disk and how each one's last load went, with a reload. */ 467function extensionList ( scope : Scope , shared : Shared ) : Element { 468const busy = ( button : HTMLButtonElement , work : Promise < unknown >) => { 469button . disabled = true ; 470return work . finally (() => ( button . disabled = false )); 471}; 472const duration = ( ms : number ) => 473ms < 1000 ? ` ${ ms . toFixed ( 1 )} ms` : ` ${( ms / 1000 ). toFixed ( 2 )} s` ; 474type ProseState = { 475details : HTMLDetailsElement ; 476fieldset : HTMLFieldSetElement ; 477textarea : HTMLTextAreaElement ; 478status : HTMLElement ; 479dirty : boolean ; 480defaultText : string ; 481}; 482const editors = new Map < string , ProseState >(); 483let overrides : Record < string , string > | null = null ; 484let saving = Promise . resolve (); 485const draw = ( name : string ) => { 486const state = editors . get ( name ); 487if ( ! state ) return ; 488if ( overrides === null ) { 489state . fieldset . disabled = true ; 490return ; 491} 492state . fieldset . disabled = false ; 493const effective = overrides [ name ] ?? state . defaultText ; 494if ( ! state . dirty && state . textarea !== document . activeElement ) { 495state . textarea . value = effective ; 496} 497}; 498scope . add ( 499watch <{ systemPrompt ?: { promptProse ?: Record < string , string > } }>( "settings" , ( value ) => { 500overrides = value . systemPrompt ?. promptProse ?? {}; 501for ( const name of editors . keys ()) draw ( name ); 502}), 503); 504const proseEditor = ( name : string , prose : { default : string } | null ) : Element | undefined => { 505if ( ! prose ) return undefined ; 506const cached = editors . get ( name ); 507if ( cached ) { 508if ( cached . defaultText !== prose . default ) { 509cached . defaultText = prose . default ; 510draw ( name ); 511} 512return cached . details ; 513} 514const textarea = < textarea rows = { 6 } /> as HTMLTextAreaElement ; 515const status = ( 516< p 517class = "configuration-status" 518role = "status" 519aria - live = "polite" 520/ > 521) as HTMLElement ; 522const fieldset = ( 523< fieldset class = "system-prompt" disabled > 524< label class = "field" > 525< span > prompt prose </ span > 526{ textarea } 527{ status } 528< /label> 529</ fieldset > 530) as HTMLFieldSetElement ; 531const details = ( 532< details > 533< summary > system prompt </ summary > 534{ fieldset } 535</ details > 536) as HTMLDetailsElement ; 537const state : ProseState = { 538details , 539fieldset , 540textarea , 541status , 542dirty : false , 543defaultText : prose . default , 544}; 545editors . set ( name , state ); 546textarea . setAttribute ( "aria-label" , ` ${ name } prompt prose` ); 547textarea . addEventListener ( "input" , () => { 548state . dirty = true ; 549status . textContent = "" ; 550}); 551textarea . addEventListener ( "blur" , () => { 552if ( ! state . dirty ) { 553draw ( name ); 554return ; 555} 556const value = textarea . value ; 557saving = saving . then ( async () => { 558const effective = overrides ?.[ name ] ?? state . defaultText ; 559if ( value !== effective ) { 560status . classList . remove ( "error" ); 561status . textContent = "saving…" ; 562try { 563const saved = await api <{ systemPrompt : { promptProse ?: Record < string , string > } }>( 564"PATCH" , 565"/api/settings" , 566{ systemPrompt : { promptProse : { [ name ] : value } } }, 567); 568overrides = saved . systemPrompt . promptProse ?? {}; 569status . textContent = "saved" ; 570} catch ( cause ) { 571status . classList . add ( "error" ); 572status . textContent = cause instanceof Error ? cause . message : String ( cause ); 573return ; 574} 575} 576if ( textarea . value === value ) state . dirty = false ; 577draw ( name ); 578}); 579}); 580draw ( name ); 581return details ; 582}; 583const list = < div class = "provider-list extension-list" / >; 584const listed = shared . extensions . derive (( all ) => 585all . filter (( extension ) => ! extension . builtin || extension . promptProse ) 586); 587const rows = keyed < ExtensionInfo , string >( 588list , 589( extension ) => extension . name , 590( initial ) => { 591const node = < div class = "extension" / > as HTMLDivElement ; 592const head = < div class = "provider-row" / > as HTMLDivElement ; 593node . append ( head ); 594let shown : Element | undefined ; 595const update = ( extension : ExtensionInfo ) => { 596head . replaceChildren ( 597< span class = "model-name" >{ extension . name }</ span >, 598< span class = "description" >{ extension . description ?? "" }</ span >, 599... ( extension . startup 600? [ 601< details class = "extension-startup" > 602< summary > 603{ extension . startup . status === "failed" ? "startup failed" : "startup" }{ " " } 604{ duration ( extension . startup . totalMs )} 605</ summary > 606< dl > 607{ extension . startup . phases . map (( phase ) => ( 608<> 609< dt > 610{ phase . name === "import" 611? "module import" 612: phase . name === "frontend" 613? phase . source === "cache" 614? "frontend cache restore" 615: phase . source === "build" 616? "frontend build" 617: "frontend" 618: "installation" } 619</ dt > 620< dd >{ duration ( phase . durationMs )}</ dd > 621</> 622))} 623</ dl > 624< / details > , 625] 626: []), 627< span class = { extension . status === "loaded" ? "ok" : "error" }> 628{ extension . status === "loaded" 629? "loaded" 630: extension . running 631? "reload failed" 632: "failed" } 633</ span > , 634...(extension.builtin ? [] : [ 635< span class =" actions " > 636< button 637type = "button" 638_onclick = { function () { 639void busy ( this , api ( "POST" , `/api/extensions/ ${ extension . name } /reload` )). catch ( 640console . error , 641); 642}} 643> 644reload 645</ button > 646</ span > , 647]), 648...(extension.directory 649? [ < code title = { extension . directory }>{ extension . directory }</ code > ] 650: []), 651); 652const failed = node.querySelector("pre.error"); 653if (extension.error) { 654if ( failed ) failed . textContent = extension . error ; 655else node . append (< pre class = "error" >{ extension . error }</ pre >); 656} else failed ?. remove (); 657// the same editor node while its registration persists, so a focused draft keeps focus 658const editor = proseEditor ( extension . name , extension . promptProse ); 659if ( editor !== shown ) { 660shown ?. remove (); 661shown = editor ; 662if ( editor ) node . insertBefore ( editor , node . querySelector ( "pre.error" ) ?? null ); 663} 664}; 665update ( initial ); 666return { node , update }; 667}, 668); 669const sync = () = > rows . sync ( listed . get ()); 670sync (); 671scope . add ( listed . subscribe ( sync ). unsubscribe ); 672scope . add ( rows . dispose ); 673return list ; 674} 675 676type EnvironmentSettings = { ssh : string ; deno ?: string }; 677 678/** The machines conversations may work on besides this one, and a form for another. */ 679function environmentList ( scope : Scope ) : Element { 680const configured = new Signal < Readonly < Record < string , EnvironmentSettings >>>({}); 681const error = < p class = "error" / > as HTMLParagraphElement ; 682let allowed = true ; 683const local = ( 684< input 685type = "checkbox" 686checked 687_onchange = { async function () { 688this . disabled = true ; 689try { 690const saved = await api <{ allowLocalEnvironment ?: boolean }>( "PATCH" , "/api/settings" , { 691allowLocalEnvironment : this . checked , 692}); 693allowed = saved . allowLocalEnvironment !== false ; 694error . textContent = "" ; 695} catch ( cause ) { 696error . textContent = ( cause as Error ). message ; 697} finally { 698this . checked = allowed ; 699this . disabled = false ; 700} 701}} 702/> 703) as HTMLInputElement ; 704scope . add ( 705watch <{ environments ?: Record < string , EnvironmentSettings >; allowLocalEnvironment ?: boolean }>( 706"settings" , 707( value ) => { 708configured . set ( value . environments ?? {}); 709allowed = value . allowLocalEnvironment !== false ; 710local . checked = allowed ; 711}, 712), 713); 714const change = ( next : Record < string , EnvironmentSettings >) = > 715api ( "PATCH" , "/api/settings" , { environments : next }). then ( 716() => ( error . textContent = "" , true ), 717( cause ) => ( error . textContent = cause . message , false ), 718); 719const list = < div class = "provider-list environment-list" / >; 720swap ( 721scope , 722list , 723configured , 724( all ) => 725Object . entries ( all ). map (([ name , { ssh , deno }]) => ( 726< div class = "environment-row" > 727< span class = "model-name" title = { name }>{ name }< /span> 728< code title = { ssh }>{ ssh }</ code > 729< code title = { deno ?? "deno" }>{ deno ?? "deno" }< /code> 730< button 731type = "button" 732class = "danger" 733_onclick = {() => { 734const { [ name ] : _ , ... rest } = configured . get (); 735void change ( rest ); 736}} 737> 738remove 739< /button> 740</ div > 741)), 742); 743const name = < input placeholder = "name" required / > as HTMLInputElement ; 744const ssh = < input placeholder = "ssh destination" required /> as HTMLInputElement ; 745const deno = < input placeholder = "remote deno path (default: deno on PATH)" /> as HTMLInputElement ; 746return ( 747< div class = "environments" > 748< label class = "local-environment" >{ local } allow conversations to work on this machine < / label> 749< p class = "desc" > when off , they can only use the ssh environments below < /p> 750{ list } 751< form 752class = "environment-form" 753_onsubmit = { async ( event ) => { 754event . preventDefault (); 755const added = await change ({ 756... configured . get (), 757[ name . value . trim ()] : { 758ssh : ssh . value . trim (), 759... ( deno . value . trim () ? { deno : deno . value . trim () } : {}), 760}, 761}); 762if ( added ) name . value = ssh . value = deno . value = "" ; 763}} 764> 765{ name } 766{ ssh } 767{ deno } 768< button type = "submit" class = "primary" > add < /button> 769</ form > 770{ error } 771< /div> 772); 773} 774 775type SystemPromptSettings = { 776userName : string ; 777generalGuidelines : string ; 778promptProse ?: Record < string , string >; 779}; 780 781/** The switch for finished-turn announcements, whose permission the browser grants only from a click like it. */ 782function notificationSettings () : Element { 783const available = notificationsAvailable (); 784const input = ( 785< input 786type = "checkbox" 787checked = { available && notificationsOn ()} 788disabled = { ! available } 789/> 790) as HTMLInputElement ; 791const status = < p class = "desc" role = "status" / > as HTMLElement ; 792status . textContent = available ? "" : "needs a secure context (https / localhost)" ; 793input . addEventListener ( "change" , async () => { 794const wanted = input . checked ; 795const on = await setNotifications ( wanted ); 796input . checked = on ; 797status . textContent = wanted && ! on ? "the browser blocked notifications for this page" : "" ; 798}); 799return ( 800< div class = "notify" > 801< label class = "notify-row" > 802{ input } 803< span > notify when a conversation finishes a turn < /span> 804</ label > 805{ status } 806< / div> 807) as HTMLElement ; 808} 809 810function systemPromptEditor ( scope : Scope ) : Element { 811let current : SystemPromptSettings | null = null ; 812let saving = Promise . resolve (); 813const dirty = new Set < keyof SystemPromptSettings >(); 814const status = < p class = "configuration-status" role = "status" aria - live = "polite" / > as HTMLElement ; 815const fields = ([ 816[ "userName" , "user's name" ], 817[ "generalGuidelines" , "general guidelines" ], 818] as const ). map (([ key , label ]) => { 819const input = ( key === "userName" ? < input /> : < textarea rows = { 8 } />) as 820| HTMLInputElement 821| HTMLTextAreaElement ; 822input . addEventListener ( "input" , () => { 823dirty . add ( key ); 824status . textContent = "" ; 825}); 826input . addEventListener ( "blur" , () => { 827if ( ! current || ! dirty . has ( key )) { 828draw (); 829return ; 830} 831const value = input . value ; 832saving = saving . then ( async () => { 833if ( ! current ) return ; 834if ( value !== current [ key ]) { 835status . classList . remove ( "error" ); 836status . textContent = "saving…" ; 837try { 838const saved = await api <{ systemPrompt : SystemPromptSettings }>( 839"PATCH" , 840"/api/settings" , 841{ systemPrompt : { [ key ] : value } }, 842); 843current = saved . systemPrompt ; 844status . textContent = "saved" ; 845} catch ( cause ) { 846status . classList . add ( "error" ); 847status . textContent = cause instanceof Error ? cause . message : String ( cause ); 848return ; 849} 850} 851if ( input . value === value ) dirty . delete ( key ); 852draw (); 853}); 854}); 855return { 856key , 857input , 858element : ( 859< label class = "field" > 860< span >{ label }</ span > 861{ input } 862< / label> 863), 864}; 865}); 866const draw = () = > { 867if ( ! current ) return ; 868for ( const { key , input } of fields ) { 869if ( ! dirty . has ( key ) && input !== document . activeElement ) input . value = current [ key ]; 870} 871}; 872const fieldset = ( 873< fieldset class = "system-prompt" disabled > 874{ fields . map (({ element }) => element )} 875{ status } 876< / fieldset> 877) as HTMLFieldSetElement ; 878scope . add ( watch <{ systemPrompt ?: SystemPromptSettings }>( "settings" , ( value ) => { 879current = value . systemPrompt ?? null ; 880fieldset . disabled = current === null ; 881draw (); 882})); 883return fieldset ; 884} 885 886/** Which of the logged-in providers' models conversations may use, and in what order they are offered. */ 887export function settings ( scope : Scope , shared : Shared ) : Element [] { 888const { models , enabled } = shared ; 889const filter = new Signal ( "" ); 890const providers = < div class = "model-list" / >; 891const expanded = new Set < string >(); 892const changed = new Signal ( 0 ); 893for ( const source of [ models , enabled , filter ]) { 894scope . add ( source . subscribe (() => changed . set ( changed . get () + 1 )). unsubscribe ); 895} 896swap ( scope , providers , changed , () => { 897const query = filter . get (). trim (). toLowerCase (); 898const on = new Set ( enabled . get (). map ( modelKey )); 899const matches = models . get (). filter (( model ) => 900query === "" || ` ${ model . provider } / ${ model . id } ${ model . name } ` . toLowerCase (). includes ( query ) 901); 902return [ ... Map . groupBy ( matches , ( model ) => model . provider )]. map (([ provider , all ]) => { 903const count = all . filter (( model ) => on . has ( modelKey ( model ))). length ; 904return ( 905< details 906class = "provider" 907open = { query !== "" || expanded . has ( provider )} 908_ontoggle = { function () { 909if ( query !== "" || ! this . isConnected ) return ; 910if ( this . open ) expanded . add ( provider ); 911else expanded . delete ( provider ); 912}} 913> 914< summary > 915< span class = "provider-name" >{ provider }< / span> 916< span class = "count" >{ ` ${ count } of ${ all . length } enabled` }</ span > 917< / summary > 918{ all . map (( model ) => ( 919< label class = "model-row" > 920< input 921type = "checkbox" 922checked = { on . has ( modelKey ( model ))} 923_onchange = { function () { 924const rest = enabled . get (). filter (( m ) => modelKey ( m ) !== modelKey ( model )); 925save ( this . checked ? [ ... rest , refOf ( model )] : rest ); 926}} 927/> 928{ describe ( model , modelKey ( model ))} 929</ label > 930))} 931</ details > 932); 933}); 934}); 935 936return [ 937< header class = "bar" > 938< span class = "bar-title" > settings < / span> 939</ header >, 940< div class = "pane settings" > 941< h2 > enabled models </ h2 > 942< p class = "desc" > 943drag to reorder , uncheck to remove 944< / p> 945< section 946class = "enabled" 947hidden = { computed ( scope , [ enabled ], () => enabled . get (). length === 0 )} 948> 949{ enabledList ( scope , models , enabled )} 950< /section> 951< h2 > environments </ h2 > 952< p class = "desc" > 953remote machines for conversations 954< /p> 955{ environmentList ( scope )} 956< h2 > system prompt </ h2 > 957< p class = "desc" > 958< code > $USER </ code > will be interpolated in guidelines to the configured name 959< /p> 960{ systemPromptEditor ( scope )} 961< h2 > extensions </ h2 > 962< p class = "desc" > 963from < code > … / berry / extensions / < / code > and < code > ~/.config/berry/extensions/ </ code > ; { " " } 964extensions auto-reload on file change 965</ p > 966{ extensionList ( scope , shared )} 967< h2 > notifications < / h2> 968< p class = "desc" > 969( only while open in a tab ) 970< /p> 971{ notificationSettings ()} 972< h2 > available models </ h2 > 973< p class = "desc" > all models from logged - in providers < /p> 974< input 975type = "search" 976class = "filter" 977placeholder = "filter models" 978_oninput = { function () { 979filter . set ( this . value ); 980}} 981/ > 982{ providers } 983< h2 > provider logins < / h2> 984< p class = "desc" > 985logins shared with pi : < code > ~ /.pi / agent / auth . json < /code> 986</ p > 987{ providerList ( scope , shared )} 988< /div>, 989]; 990}