diff --git a/src/haz3lcore/assistant/TyDi.re b/src/haz3lcore/assistant/TyDi.re index 1118fc115f..4d04174639 100644 --- a/src/haz3lcore/assistant/TyDi.re +++ b/src/haz3lcore/assistant/TyDi.re @@ -68,12 +68,13 @@ let suffix_of = (candidate: Token.t, current: Token.t): option(Token.t) => { }; /* Returns the text content of the suggestion buffer */ -let get_buffer = (z: Zipper.t): option(Token.t) => +let get_buffer = (z: Zipper.t): option(Token.t) => { switch (z.selection.mode, z.selection.content) { | (Buffer(Unparsed), [Tile({label: [completion], _})]) => Some(completion) | _ => None }; +}; /* Populates the suggestion buffer with a type-directed suggestion */ let set_buffer = (~info_map: Statics.Map.t, z: Zipper.t): option(Zipper.t) => { @@ -97,3 +98,18 @@ let set_buffer = (~info_map: Statics.Map.t, z: Zipper.t): option(Zipper.t) => { let z = Zipper.set_buffer(z, ~content, ~mode=Unparsed); Some(z); }; + +let set_llm_buffer = + (~info_map: Statics.Map.t, z: Zipper.t, response: string) + : option(Zipper.t) => { + let* index = Indicated.index(z); + let* ci = Id.Map.find_opt(index, info_map); + let content = + mk_unparsed_buffer( + ~sort=Info.sort_of(ci), + z.relatives.siblings, + response, + ); + let z = Zipper.set_llm_buffer(z, ~content, ~mode=Unparsed); + Some(z); +}; diff --git a/src/haz3lcore/dune b/src/haz3lcore/dune index a0d9770816..edbbffa413 100644 --- a/src/haz3lcore/dune +++ b/src/haz3lcore/dune @@ -2,7 +2,7 @@ (library (name haz3lcore) - (libraries util sexplib unionFind uuidm virtual_dom yojson core) + (libraries util unix sexplib unionFind uuidm virtual_dom yojson core) (js_of_ocaml) (instrumentation (backend bisect_ppx)) diff --git a/src/haz3lcore/lang/Form.re b/src/haz3lcore/lang/Form.re index 512962e778..c751505cac 100644 --- a/src/haz3lcore/lang/Form.re +++ b/src/haz3lcore/lang/Form.re @@ -202,7 +202,10 @@ let const_mono_delims = base_typs @ bools @ [undefined, wild, empty_list, empty_tuple, empty_string]; let explicit_hole = "?"; +let llm_hole = "??"; let is_explicit_hole = t => t == explicit_hole; + +let is_llm_hole = t => t == llm_hole; let bad_token_cls: string => bad_token_cls = t => switch () { @@ -222,6 +225,13 @@ let atomic_forms: list((string, (string => bool, list(Mold.t)))) = [ [mk_op(Exp, []), mk_op(Pat, []), mk_op(Typ, []), mk_op(TPat, [])], ), ), + ( + "llm_hole", + ( + is_llm_hole, + [mk_op(Exp, []), mk_op(Pat, []), mk_op(Typ, []), mk_op(TPat, [])], + ), + ), ("wild", (is_wild, [mk_op(Pat, [])])), ("string", (is_string, [mk_op(Exp, []), mk_op(Pat, [])])), ("int_lit", (is_int, [mk_op(Exp, []), mk_op(Pat, [])])), diff --git a/src/haz3lcore/statics/MakeTerm.re b/src/haz3lcore/statics/MakeTerm.re index 9c28029e00..8d7a17a5a8 100644 --- a/src/haz3lcore/statics/MakeTerm.re +++ b/src/haz3lcore/statics/MakeTerm.re @@ -202,7 +202,8 @@ and exp_term: unsorted => (Exp.term, list(Id.t)) = { Match(scrut, rules), ids, ) - | ([t], []) when t != " " && !Form.is_explicit_hole(t) => + | ([t], []) + when t != " " && !Form.is_explicit_hole(t) && !Form.is_llm_hole(t) => ret(Invalid(t)) | _ => ret(hole(tm)) } @@ -351,7 +352,9 @@ and pat_term: unsorted => (Pat.term, list(Id.t)) = { | ([t], []) when Form.is_wild(t) => Wild | ([t], []) when Form.is_ctr(t) => Constructor(t, Unknown(Internal) |> Typ.fresh) - | ([t], []) when t != " " && !Form.is_explicit_hole(t) => + | ([t], []) + when + t != " " && !Form.is_explicit_hole(t) && !Form.is_llm_hole(t) => Invalid(t) | (["(", ")"], [Pat(body)]) => Parens(body) | (["[", "]"], [Pat(body)]) => @@ -415,7 +418,9 @@ and typ_term: unsorted => (Typ.term, list(Id.t)) = { | ([t], []) when Form.is_typ_var(t) => Var(t) | (["(", ")"], [Typ(body)]) => Parens(body) | (["[", "]"], [Typ(body)]) => List(body) - | ([t], []) when t != " " && !Form.is_explicit_hole(t) => + | ([t], []) + when + t != " " && !Form.is_explicit_hole(t) && !Form.is_llm_hole(t) => Unknown(Hole(Invalid(t))) | _ => hole(tm) }, @@ -483,7 +488,9 @@ and tpat_term: unsorted => TPat.term = { ret( switch (tile) { | ([t], []) when Form.is_typ_var(t) => Var(t) - | ([t], []) when t != " " && !Form.is_explicit_hole(t) => + | ([t], []) + when + t != " " && !Form.is_explicit_hole(t) && !Form.is_llm_hole(t) => Invalid(t) | _ => hole(tm) }, diff --git a/src/haz3lcore/zipper/Selection.re b/src/haz3lcore/zipper/Selection.re index 92d6509fb8..7459987caa 100644 --- a/src/haz3lcore/zipper/Selection.re +++ b/src/haz3lcore/zipper/Selection.re @@ -26,6 +26,9 @@ let mk = (~mode=Normal, ~focus=Direction.Left, content: Segment.t) => { let mk_buffer = buffer => mk(~mode=Buffer(buffer), ~focus=Direction.Left); +let mk_llm_buffer = buffer => + mk(~mode=Buffer(buffer), ~focus=Direction.Right); + let is_buffer: t => bool = fun | {mode: Buffer(_), _} => true diff --git a/src/haz3lcore/zipper/Zipper.re b/src/haz3lcore/zipper/Zipper.re index f87fb964e6..299d04ef67 100644 --- a/src/haz3lcore/zipper/Zipper.re +++ b/src/haz3lcore/zipper/Zipper.re @@ -326,6 +326,11 @@ let set_buffer = (z: t, ~mode: Selection.buffer, ~content: Segment.t): t => { selection: Selection.mk_buffer(mode, content), }; +let set_llm_buffer = (z: t, ~mode: Selection.buffer, ~content: Segment.t): t => { + ...z, + selection: Selection.mk_llm_buffer(mode, content), +}; + let is_linebreak_to_right_of_caret = ({relatives: {siblings: (_, r), _}, _}: t): bool => { switch (r) { diff --git a/src/haz3lcore/zipper/action/Action.re b/src/haz3lcore/zipper/action/Action.re index 6d9d3a36f5..4dcc1e724f 100644 --- a/src/haz3lcore/zipper/action/Action.re +++ b/src/haz3lcore/zipper/action/Action.re @@ -59,7 +59,8 @@ type project = [@deriving (show({with_path: false}), sexp, yojson)] type agent = - | TyDi; + | TyDi + | LLMSug(string); [@deriving (show({with_path: false}), sexp, yojson)] type buffer = diff --git a/src/haz3lcore/zipper/action/Perform.re b/src/haz3lcore/zipper/action/Perform.re index 89c9059ac1..64a07f4f1b 100644 --- a/src/haz3lcore/zipper/action/Perform.re +++ b/src/haz3lcore/zipper/action/Perform.re @@ -7,12 +7,18 @@ let buffer_clear = (z: t): t => | _ => z }; -let set_buffer = (info_map: Statics.Map.t, z: t): t => +let set_tydi_buffer = (info_map: Statics.Map.t, z: t): t => switch (TyDi.set_buffer(~info_map, z)) { | None => z | Some(z) => z }; +let set_llm_buffer = (info_map: Statics.Map.t, z: t, response: string): t => + switch (TyDi.set_llm_buffer(~info_map, z, response)) { + | None => z + | Some(z) => z + }; + let go_z = ( ~settings as _: CoreSettings.t, @@ -98,7 +104,9 @@ let go_z = | None => Error(CantReparse) | Some(z) => Ok(z) } - | Buffer(Set(TyDi)) => Ok(set_buffer(statics.info_map, z)) + | Buffer(Set(TyDi)) => Ok(set_tydi_buffer(statics.info_map, z)) + | Buffer(Set(LLMSug(response))) => + Ok(set_llm_buffer(statics.info_map, z, response)) | Buffer(Accept) => switch (buffer_accept(z)) { | None => Error(CantAccept) diff --git a/src/haz3lweb/Settings.re b/src/haz3lweb/Settings.re index d9d369dfe3..8c46219e8c 100644 --- a/src/haz3lweb/Settings.re +++ b/src/haz3lweb/Settings.re @@ -11,6 +11,8 @@ module Model = { instructor_mode: bool, benchmark: bool, explainThis: ExplainThisModel.Settings.t, + assistant: AssistantSettings.t, + sidebar: SidebarModel.Settings.t, }; let init = { @@ -38,10 +40,19 @@ module Model = { instructor_mode: true, benchmark: false, explainThis: { - show: true, show_feedback: false, highlight: NoHighlight, }, + assistant: { + llm: false, + lsp: false, + ongoing_chat: false, + mode: CodeSuggestion, + }, + sidebar: { + window: LanguageDocumentation, + show: true, + }, }; let fix_instructor_mode = settings => @@ -91,7 +102,9 @@ module Update = { | ContextInspector | InstructorMode | Evaluation(evaluation) - | ExplainThis(ExplainThisModel.Settings.action); + | Sidebar(SidebarModel.Settings.action) + | ExplainThis(ExplainThisModel.Settings.action) + | Assistant(AssistantSettings.action); let update = (action, settings: Model.t): Updated.t(Model.t) => { ( @@ -174,11 +187,21 @@ module Update = { evaluation, }, }; - | ExplainThis(ToggleShow) => { + | Sidebar(ToggleShow) => { ...settings, - explainThis: { - ...settings.explainThis, - show: !settings.explainThis.show, + sidebar: { + ...settings.sidebar, + show: !settings.sidebar.show, + }, + } + | Sidebar(SwitchWindow(windowToSwitchTo)) => { + ...settings, + sidebar: { + show: + !settings.sidebar.show + ? true + : settings.sidebar.window == windowToSwitchTo ? false : true, + window: windowToSwitchTo, }, } | ExplainThis(ToggleShowFeedback) => { @@ -200,6 +223,34 @@ module Update = { }; let explainThis = {...settings.explainThis, highlight}; {...settings, explainThis}; + | Assistant(ToggleLLM) => { + ...settings, + assistant: { + ...settings.assistant, + llm: !settings.assistant.llm, + }, + } + | Assistant(ToggleLSP) => { + ...settings, + assistant: { + ...settings.assistant, + lsp: !settings.assistant.lsp, + }, + } + | Assistant(UpdateChatStatus) => { + ...settings, + assistant: { + ...settings.assistant, + ongoing_chat: !settings.assistant.ongoing_chat, + }, + } + | Assistant(SwitchMode(mode)) => { + ...settings, + assistant: { + ...settings.assistant, + mode, + }, + } | Benchmark => {...settings, benchmark: !settings.benchmark} | Captions => {...settings, captions: !settings.captions} | SecondaryIcons => { diff --git a/src/haz3lweb/Store.re b/src/haz3lweb/Store.re index 0665dcbce7..8827dd66e9 100644 --- a/src/haz3lweb/Store.re +++ b/src/haz3lweb/Store.re @@ -4,6 +4,7 @@ open Util; type key = | Settings | ExplainThis + | Assistant | Mode | Scratch | Documentation @@ -14,6 +15,7 @@ let key_to_string = fun | Settings => "SETTINGS" | ExplainThis => "ExplainThisModel" + | Assistant => "AssistantModel" | Mode => "MODE" | Scratch => "SAVE_SCRATCH" | Documentation => "SAVE_DOCUMENTATION" @@ -69,3 +71,17 @@ module F = save(data); }; }; + +module Generic = { + let prefix: string = "KEY_STORE_"; + + let full_key = (key: string): string => { + prefix ++ key; + }; + + let save = (key: string, value: string): unit => + JsUtil.set_localstore(full_key(key), value); + + let load = (key: string): option(string) => + JsUtil.get_localstore(full_key(key)); +}; diff --git a/src/haz3lweb/app/common/Icons.re b/src/haz3lweb/app/common/Icons.re index 52d4e130db..d9fc7341e0 100644 --- a/src/haz3lweb/app/common/Icons.re +++ b/src/haz3lweb/app/common/Icons.re @@ -231,3 +231,85 @@ let command_palette_sparkle = "m554.76 426.6c6.5195-23.285 24.715-41.48 48-48-23.297-6.5-41.5-24.707-48-48-6.5 23.293-24.707 41.5-48 48 23.281 6.5195 41.477 24.715 48 48z", ], ); + +let assistant = + simple_icon( + ~view="0 0 512 512", + [ + "M440.86,303.607c-27.106,0-59.31-6.311-68.409-24.022c-5.41-10.534,0.128-22.059,5.728-29.872 + c21.037-29.36,32.158-62.108,32.158-94.703C410.336,69.538,341.101,0,255.999,0S101.662,69.538,101.662,155.01 + c0,32.595,11.12,65.343,32.158,94.703c5.598,7.813,11.138,19.339,5.728,29.872c-9.099,17.712-41.303,24.022-68.409,24.022 + c-30.01,0-54.424,24.497-54.424,54.609s24.415,54.609,54.424,54.609c13.878,0,32.714-3.057,49.694-7.208 + c-3.563,2.705-7.187,5.417-10.638,7.91c-21.305,15.39-28.094,40.131-17.718,64.568c6.132,14.441,18.071,25.704,32.76,30.902 + c13.514,4.781,27.708,3.827,39.96-2.695c35.939-19.129,59.199-51.213,76.181-74.639c4.617-6.369,10.521-14.513,14.62-19.001 + c4.099,4.489,10.003,12.633,14.621,19.001c16.983,23.426,40.242,55.509,76.181,74.639c7.114,3.786,14.882,5.698,22.793,5.698 + c5.713,0,11.5-0.997,17.168-3.002c14.688-5.197,26.629-16.46,32.76-30.902c10.375-24.438,3.587-49.178-17.717-64.568 + c-3.453-2.493-7.075-5.206-10.639-7.91c16.979,4.15,35.818,7.208,49.694,7.208c30.01,0,54.424-24.497,54.424-54.609 + C495.285,328.106,470.87,303.607,440.86,303.607z M204.312,193.512c-11.237,0-20.381-9.291-20.381-20.753 + c0-11.463,9.144-20.747,20.381-20.747c11.251,0,20.394,9.284,20.394,20.747C224.706,184.22,215.564,193.512,204.312,193.512z + M307.532,193.666c-11.346,0-20.55-9.36-20.55-20.908c0-11.547,9.204-20.908,20.55-20.908c11.332,0,20.535,9.36,20.535,20.908 + C328.067,184.306,318.865,193.666,307.532,193.666z", + ], + ); + +let explain_this = + simple_icon( + ~view="0 0 1024 1024", + [ + "M263.508 346.359c0-11.782 9.551-21.333 21.333-21.333h303.012c11.78 0 21.333 9.551 21.333 21.333s-9.553 21.333-21.333 21.333H284.841c-11.782 0-21.333-9.551-21.333-21.333zm21.333 92.937c-11.782 0-21.333 9.553-21.333 21.333 0 11.785 9.551 21.333 21.333 21.333h303.012c11.78 0 21.333-9.549 21.333-21.333 0-11.78-9.553-21.333-21.333-21.333H284.841zm-21.333 135.599c0-11.78 9.551-21.333 21.333-21.333h303.012c11.78 0 21.333 9.553 21.333 21.333 0 11.785-9.553 21.333-21.333 21.333H284.841c-11.782 0-21.333-9.549-21.333-21.333zm21.333 92.535c-11.782 0-21.333 9.553-21.333 21.333 0 11.785 9.551 21.333 21.333 21.333h303.012c11.78 0 21.333-9.549 21.333-21.333 0-11.78-9.553-21.333-21.333-21.333H284.841z", + "M325.731 43.151h15.654c1.387-.283 2.823-.432 4.294-.432s2.907.149 4.294.432H654.22c37.875 0 68.74 30.919 68.74 68.78v649.225c0 37.858-30.865 68.779-68.74 68.779H218.073c-37.873 0-68.741-30.921-68.741-68.779V212.754c0-.922.058-1.831.172-2.722.466-11.074 4.843-22.22 13.986-31.371L285.747 56.306c11.501-11.236 26.231-15.109 39.984-13.155zM193.673 208.819L315.626 86.765c.943-.899 1.808-1.238 2.577-1.366.895-.149 1.968-.049 3.028.39 1.055.437 1.833 1.1 2.312 1.78.366.52.73 1.278.803 2.512v70.051c0 .256.004.511.013.765v38.38c0 9.981-8.243 18.205-18.173 18.205H197.149c-1.328 0-2.141-.36-2.728-.777-.686-.486-1.363-1.285-1.806-2.354s-.529-2.115-.384-2.956c.124-.722.455-1.588 1.441-2.575zm173.34-123.001v3.525c.009.399.013.8.013 1.202v108.731c0 33.512-27.312 60.872-60.839 60.872L192 260.151v501.005c0 14.327 11.799 26.112 26.074 26.112h436.147c14.276 0 26.074-11.785 26.074-26.112V111.931c0-14.33-11.797-26.113-26.074-26.113H367.013z", + "M777.485 128.521c-11.785 0-21.333 9.551-21.333 21.333s9.549 21.333 21.333 21.333h28.442c14.276 0 26.074 11.783 26.074 26.113v715.254c0 14.332-11.797 26.112-26.074 26.112H369.78c-14.275 0-26.074-11.785-26.074-26.112v-28.075c0-11.78-9.551-21.333-21.333-21.333s-21.333 9.553-21.333 21.333v28.075c0 37.862 30.868 68.779 68.741 68.779h436.147c37.875 0 68.74-30.916 68.74-68.779V197.3c0-37.861-30.865-68.78-68.74-68.78h-28.442z", + ], + ); + +let expand = + simple_icon( + ~view="0 0 24 24", + [ + "M8.70710678,12 L19.5,12 C19.7761424,12 20,12.2238576 20,12.5 C20,12.7761424 19.7761424,13 19.5,13 L8.70710678,13 L11.8535534,16.1464466 C12.0488155,16.3417088 12.0488155,16.6582912 11.8535534,16.8535534 C11.6582912,17.0488155 11.3417088,17.0488155 11.1464466,16.8535534 L7.14644661,12.8535534 C6.95118446,12.6582912 6.95118446,12.3417088 7.14644661,12.1464466 L11.1464466,8.14644661 C11.3417088,7.95118446 11.6582912,7.95118446 11.8535534,8.14644661 C12.0488155,8.34170876 12.0488155,8.65829124 11.8535534,8.85355339 L8.70710678,12 L8.70710678,12 Z M4,5.5 C4,5.22385763 4.22385763,5 4.5,5 C4.77614237,5 5,5.22385763 5,5.5 L5,19.5 C5,19.7761424 4.77614237,20 4.5,20 C4.22385763,20 4,19.7761424 4,19.5 L4,5.5 Z", + ], + ); + +let collapse = + simple_icon( + ~view="0 0 24 24", + [ + "M15.2928932,12 L12.1464466,8.85355339 C11.9511845,8.65829124 11.9511845,8.34170876 12.1464466,8.14644661 C12.3417088,7.95118446 12.6582912,7.95118446 12.8535534,8.14644661 L16.8535534,12.1464466 C17.0488155,12.3417088 17.0488155,12.6582912 16.8535534,12.8535534 L12.8535534,16.8535534 C12.6582912,17.0488155 12.3417088,17.0488155 12.1464466,16.8535534 C11.9511845,16.6582912 11.9511845,16.3417088 12.1464466,16.1464466 L15.2928932,13 L4.5,13 C4.22385763,13 4,12.7761424 4,12.5 C4,12.2238576 4.22385763,12 4.5,12 L15.2928932,12 Z M19,5.5 C19,5.22385763 19.2238576,5 19.5,5 C19.7761424,5 20,5.22385763 20,5.5 L20,19.5 C20,19.7761424 19.7761424,20 19.5,20 C19.2238576,20 19,19.7761424 19,19.5 L19,5.5 Z", + ], + ); +let send = + simple_icon( + ~view="0 0 24 24", + [ + "M11.4697 3.46967C11.7626 3.17678 12.2374 3.17678 12.5303 3.46967L18.5303 9.46967C18.8232 9.76256 18.8232 10.2374 18.5303 10.5303C18.2374 10.8232 17.7626 10.8232 17.4697 10.5303L12.75 5.81066L12.75 20C12.75 20.4142 12.4142 20.75 12 20.75C11.5858 20.75 11.25 20.4142 11.25 20L11.25 5.81066L6.53033 10.5303C6.23744 10.8232 5.76256 10.8232 5.46967 10.5303C5.17678 10.2374 5.17678 9.76256 5.46967 9.46967L11.4697 3.46967Z", + ], + ); + +let add = + simple_icon( + ~view="0 0 24 24", + [ + "M12.75 9C12.75 8.58579 12.4142 8.25 12 8.25C11.5858 8.25 11.25 8.58579 11.25 9L11.25 11.25H9C8.58579 11.25 8.25 11.5858 8.25 12C8.25 12.4142 8.58579 12.75 9 12.75H11.25V15C11.25 15.4142 11.5858 15.75 12 15.75C12.4142 15.75 12.75 15.4142 12.75 15L12.75 12.75H15C15.4142 12.75 15.75 12.4142 15.75 12C15.75 11.5858 15.4142 11.25 15 11.25H12.75V9Z", + "M12 1.25C6.06294 1.25 1.25 6.06294 1.25 12C1.25 17.9371 6.06294 22.75 12 22.75C17.9371 22.75 22.75 17.9371 22.75 12C22.75 6.06294 17.9371 1.25 12 1.25ZM2.75 12C2.75 6.89137 6.89137 2.75 12 2.75C17.1086 2.75 21.25 6.89137 21.25 12C21.25 17.1086 17.1086 21.25 12 21.25C6.89137 21.25 2.75 17.1086 2.75 12Z", + ], + ); + +let history = + simple_icon( + ~view="0 0 24 24", + [ + "M5.07868 5.06891C8.87402 1.27893 15.0437 1.31923 18.8622 5.13778C22.6824 8.95797 22.7211 15.1313 18.9262 18.9262C15.1312 22.7211 8.95793 22.6824 5.13774 18.8622C2.87389 16.5984 1.93904 13.5099 2.34047 10.5812C2.39672 10.1708 2.775 9.88377 3.18537 9.94002C3.59575 9.99627 3.88282 10.3745 3.82658 10.7849C3.4866 13.2652 4.27782 15.881 6.1984 17.8016C9.44288 21.0461 14.6664 21.0646 17.8655 17.8655C21.0646 14.6664 21.046 9.44292 17.8015 6.19844C14.5587 2.95561 9.33889 2.93539 6.13935 6.12957L6.88705 6.13333C7.30126 6.13541 7.63535 6.47288 7.63327 6.88709C7.63119 7.3013 7.29372 7.63539 6.87951 7.63331L4.33396 7.62052C3.92269 7.61845 3.58981 7.28556 3.58774 6.8743L3.57495 4.32874C3.57286 3.91454 3.90696 3.57707 4.32117 3.57498C4.73538 3.5729 5.07285 3.907 5.07493 4.32121L5.07868 5.06891ZM11.9999 7.24992C12.4141 7.24992 12.7499 7.58571 12.7499 7.99992V11.6893L15.0302 13.9696C15.3231 14.2625 15.3231 14.7374 15.0302 15.0302C14.7373 15.3231 14.2624 15.3231 13.9696 15.0302L11.2499 12.3106V7.99992C11.2499 7.58571 11.5857 7.24992 11.9999 7.24992Z", + ], + ); + +let delete = + simple_icon( + ~view="0 0 24 24", + [ + "M12 2.75C11.0215 2.75 10.1871 3.37503 9.87787 4.24993C9.73983 4.64047 9.31134 4.84517 8.9208 4.70713C8.53026 4.56909 8.32557 4.1406 8.46361 3.75007C8.97804 2.29459 10.3661 1.25 12 1.25C13.634 1.25 15.022 2.29459 15.5365 3.75007C15.6745 4.1406 15.4698 4.56909 15.0793 4.70713C14.6887 4.84517 14.2602 4.64047 14.1222 4.24993C13.813 3.37503 12.9785 2.75 12 2.75Z", + "M2.75 6C2.75 5.58579 3.08579 5.25 3.5 5.25H20.5001C20.9143 5.25 21.2501 5.58579 21.2501 6C21.2501 6.41421 20.9143 6.75 20.5001 6.75H3.5C3.08579 6.75 2.75 6.41421 2.75 6Z", + "M5.91508 8.45011C5.88753 8.03681 5.53015 7.72411 5.11686 7.75166C4.70356 7.77921 4.39085 8.13659 4.41841 8.54989L4.88186 15.5016C4.96735 16.7844 5.03641 17.8205 5.19838 18.6336C5.36678 19.4789 5.6532 20.185 6.2448 20.7384C6.83639 21.2919 7.55994 21.5307 8.41459 21.6425C9.23663 21.75 10.2751 21.75 11.5607 21.75H12.4395C13.7251 21.75 14.7635 21.75 15.5856 21.6425C16.4402 21.5307 17.1638 21.2919 17.7554 20.7384C18.347 20.185 18.6334 19.4789 18.8018 18.6336C18.9637 17.8205 19.0328 16.7844 19.1183 15.5016L19.5818 8.54989C19.6093 8.13659 19.2966 7.77921 18.8833 7.75166C18.47 7.72411 18.1126 8.03681 18.0851 8.45011L17.6251 15.3492C17.5353 16.6971 17.4712 17.6349 17.3307 18.3405C17.1943 19.025 17.004 19.3873 16.7306 19.6431C16.4572 19.8988 16.083 20.0647 15.391 20.1552C14.6776 20.2485 13.7376 20.25 12.3868 20.25H11.6134C10.2626 20.25 9.32255 20.2485 8.60915 20.1552C7.91715 20.0647 7.54299 19.8988 7.26957 19.6431C6.99616 19.3873 6.80583 19.025 6.66948 18.3405C6.52891 17.6349 6.46488 16.6971 6.37503 15.3492L5.91508 8.45011Z", + "M9.42546 10.2537C9.83762 10.2125 10.2051 10.5132 10.2464 10.9254L10.7464 15.9254C10.7876 16.3375 10.4869 16.7051 10.0747 16.7463C9.66256 16.7875 9.29502 16.4868 9.25381 16.0746L8.75381 11.0746C8.71259 10.6625 9.0133 10.2949 9.42546 10.2537Z", + "M15.2464 11.0746C15.2876 10.6625 14.9869 10.2949 14.5747 10.2537C14.1626 10.2125 13.795 10.5132 13.7538 10.9254L13.2538 15.9254C13.2126 16.3375 13.5133 16.7051 13.9255 16.7463C14.3376 16.7875 14.7051 16.4868 14.7464 16.0746L15.2464 11.0746Z", + ], + ); diff --git a/src/haz3lweb/app/editors/Editors.re b/src/haz3lweb/app/editors/Editors.re index fffd026843..b396057221 100644 --- a/src/haz3lweb/app/editors/Editors.re +++ b/src/haz3lweb/app/editors/Editors.re @@ -75,12 +75,20 @@ module Update = { // Exercises | Exercises(ExercisesMode.Update.t); - let update = (~globals: Globals.t, ~schedule_action, action, model: Model.t) => { + let update = + ( + ~globals: Globals.t, + ~schedule_action: t => unit, + ~send_insertion_info, + action, + model: Model.t, + ) => { switch (action, model) { | (Scratch(action), Scratch(m)) => let* scratch = ScratchMode.Update.update( ~schedule_action=a => schedule_action(Scratch(a)), + ~send_insertion_info, ~is_documentation=false, ~settings=globals.settings, action, @@ -92,6 +100,7 @@ module Update = { ScratchMode.Update.update( ~settings=globals.settings, ~schedule_action=a => schedule_action(Scratch(a)), + ~send_insertion_info, ~is_documentation=true, action, m, @@ -233,8 +242,9 @@ module Selection = { let default_selection = fun - | Model.Scratch(_) => Scratch(MainEditor) - | Model.Documentation(_) => Scratch(MainEditor) + | Model.Scratch(_) => Scratch(ScratchMode.Selection.Cell(MainEditor)) + | Model.Documentation(_) => + Scratch(ScratchMode.Selection.Cell(MainEditor)) | Model.Exercises(_) => Exercises((Exercise.Prelude, MainEditor)); }; @@ -260,7 +270,7 @@ module View = { fun | MakeActive(s) => signal(MakeActive(Scratch(s))), ~globals, - ~selected= + ~selection= switch (selection) { | Some(Scratch(s)) => Some(s) | _ => None @@ -274,7 +284,7 @@ module View = { fun | MakeActive(s) => signal(MakeActive(Scratch(s))), ~globals, - ~selected= + ~selection= switch (selection) { | Some(Scratch(s)) => Some(s) | _ => None diff --git a/src/haz3lweb/app/editors/code/Code.re b/src/haz3lweb/app/editors/code/Code.re index ee0cd2b564..fc261ca0d8 100644 --- a/src/haz3lweb/app/editors/code/Code.re +++ b/src/haz3lweb/app/editors/code/Code.re @@ -15,6 +15,7 @@ let of_delim' = | _ when is_in_buffer => "in-buffer" | _ when !is_consistent => "sort-inconsistent" | _ when !is_complete => "incomplete" + | [s] when s == Form.llm_hole => "llm-waiting" | [s] when s == Form.explicit_hole => "explicit-hole" | [s] when Form.is_string(s) => "string-lit" | _ => Sort.to_string(sort) diff --git a/src/haz3lweb/app/editors/code/CodeEditable.re b/src/haz3lweb/app/editors/code/CodeEditable.re index e90635a3ee..2cdfab5c75 100644 --- a/src/haz3lweb/app/editors/code/CodeEditable.re +++ b/src/haz3lweb/app/editors/code/CodeEditable.re @@ -22,7 +22,7 @@ module Update = { let update = (~settings: Settings.t, action: t, model: Model.t): Updated.t(Model.t) => { - let perform = (action, model: Model.t) => + let perform = (action: Action.t, model: Model.t) => { Editor.Update.update( ~settings=settings.core, action, @@ -59,6 +59,7 @@ module Update = { }; }, ); + }; switch (action) { | Perform(action) => perform(action, model) | Undo => diff --git a/src/haz3lweb/app/explainthis/ExplainThis.re b/src/haz3lweb/app/explainthis/ExplainThis.re index 3388bbb3ba..6627136aa2 100644 --- a/src/haz3lweb/app/explainthis/ExplainThis.re +++ b/src/haz3lweb/app/explainthis/ExplainThis.re @@ -2361,11 +2361,11 @@ let section = (~section_clss: string, ~title: string, contents: list(Node.t)) => let get_color_map = (~globals: Globals.t, ~explainThisModel: ExplainThisModel.t, info) => switch (globals.settings.explainThis.highlight) { - | All when globals.settings.explainThis.show => + | All when globals.settings.sidebar.show => let (_, (_, (color_map, _)), _) = get_doc(~globals, ~docs=explainThisModel, info, Colorings); Some(color_map); - | One(id) when globals.settings.explainThis.show => + | One(id) when globals.settings.sidebar.show => let (_, (_, (color_map, _)), _) = get_doc(~globals, ~docs=explainThisModel, info, Colorings); Some(Id.Map.filter((id', _) => id == id', color_map)); @@ -2404,15 +2404,6 @@ let view = Set(ExplainThis(SetHighlight(Toggle))), ) ), - div( - ~attrs=[ - clss(["close"]), - Attr.on_click(_ => - globals.inject_global(Set(ExplainThis(ToggleShow))) - ), - ], - [Icons.thin_x], - ), ], ), ] diff --git a/src/haz3lweb/app/explainthis/ExplainThisModel.re b/src/haz3lweb/app/explainthis/ExplainThisModel.re index e5496b7a37..ec819dff4f 100644 --- a/src/haz3lweb/app/explainthis/ExplainThisModel.re +++ b/src/haz3lweb/app/explainthis/ExplainThisModel.re @@ -44,7 +44,6 @@ module Settings = { [@deriving (show({with_path: false}), sexp, yojson)] type t = { - show: bool, show_feedback: bool, highlight, }; @@ -57,11 +56,10 @@ module Settings = { [@deriving (show({with_path: false}), sexp, yojson)] type action = - | ToggleShow | ToggleShowFeedback | SetHighlight(highlight_action); - let init = {show: true, show_feedback: false, highlight: NoHighlight}; + let init = {show_feedback: false, highlight: NoHighlight}; }; let init: t = {specificity_open: false, forms: [], groups: []}; diff --git a/src/haz3lweb/explainthis/ExplainThisUpdate.re b/src/haz3lweb/app/explainthis/ExplainThisUpdate.re similarity index 100% rename from src/haz3lweb/explainthis/ExplainThisUpdate.re rename to src/haz3lweb/app/explainthis/ExplainThisUpdate.re diff --git a/src/haz3lweb/app/helpful-assistant/Assistant.re b/src/haz3lweb/app/helpful-assistant/Assistant.re new file mode 100644 index 0000000000..36288d4808 --- /dev/null +++ b/src/haz3lweb/app/helpful-assistant/Assistant.re @@ -0,0 +1,925 @@ +module Sexp = Sexplib.Sexp; +open Haz3lcore; +open Util; +open Util.OptUtil.Syntax; +open StringUtil; + +module CodeModel = CodeEditable.Model; + +module Model = { + [@deriving (show({with_path: false}), sexp, yojson)] + type party = + | System + | LLM + | LS; + + [@deriving (show({with_path: false}), sexp, yojson)] + type message = { + party, + code: option((Segment.t, option(Id.t))), + content: string, + collapsed: bool, + }; + + [@deriving (show({with_path: false}), sexp, yojson)] + type chat = { + messages: list(message), + id: Id.t, + descriptor: string, + timestamp: float, + }; + + // We save the history of past chats as a hash map with chat IDs as keys. + [@deriving (show({with_path: false}), sexp, yojson)] + type chat_history = { + // History logs of past chats stored as hash maps with chat IDs as keys + past_simple_chats: Id.Map.t(chat), + past_suggestion_chats: Id.Map.t(chat), + past_completion_chats: Id.Map.t(chat), + }; + + [@deriving (show({with_path: false}), sexp, yojson)] + type current_chats = { + // Current active chat IDs for each mode + curr_simple_chat: Id.t, + curr_suggestion_chat: Id.t, + curr_completion_chat: Id.t, + }; + + [@deriving (show({with_path: false}), sexp, yojson)] + type t = { + current_chats, + chat_history, + llm: OpenRouter.chat_models, + show_history: bool, // TODO: Move this to AssistantSettings.re + show_api_key: bool, + }; + + let init_simple_chat = { + messages: [], + id: Id.mk(), + descriptor: "", + timestamp: Unix.time(), + }; + let init_suggestion_chat = { + messages: [], + id: Id.mk(), + descriptor: "", + timestamp: Unix.time(), + }; + let init_completion_chat = { + messages: [], + id: Id.mk(), + descriptor: "", + timestamp: Unix.time(), + }; + + // Simple helper to save a parameter in call to Id.Map.add + let add_chat_to_history = + (chat: chat, history: Id.Map.t(chat)): Id.Map.t(chat) => { + Id.Map.add(chat.id, chat, history); + }; + + // This is important when we need to display the history of chats in chronological order. + let sorted_chats = (chat_map: Id.Map.t(chat)): list(chat) => { + chat_map + |> Id.Map.bindings + |> List.map(((_, chat)) => chat) + |> List.sort((a, b) => int_of_float(b.timestamp -. a.timestamp)); + }; + + [@deriving (show({with_path: false}), sexp, yojson)] + let init: t = { + current_chats: { + curr_simple_chat: init_simple_chat.id, + curr_suggestion_chat: init_suggestion_chat.id, + curr_completion_chat: init_completion_chat.id, + }, + chat_history: { + past_simple_chats: add_chat_to_history(init_simple_chat, Id.Map.empty), + past_suggestion_chats: + add_chat_to_history(init_suggestion_chat, Id.Map.empty), + past_completion_chats: + add_chat_to_history(init_completion_chat, Id.Map.empty), + }, + llm: Gemini_Flash_Lite_2_0, + show_history: false, + show_api_key: false, + }; +}; + +module Update = { + [@deriving (show({with_path: false}), sexp, yojson)] + type t = + | SendTextMessage(Model.message) + | SendSketchMessage(Id.t, AssistantSettings.mode) + | SendErrorMessage( + string, + Info.t, + int, + Id.t, + AssistantSettings.mode, + Id.t, + ) + | ErrorRespond(string, Info.t, int, Id.t, AssistantSettings.mode, Id.t) + | Respond(Model.message, AssistantSettings.mode, Id.t) + | SetKey(string) + | NewChat + | DeleteChat(Id.t) + | History + | ToggleCollapse(int) + | SelectLLM(OpenRouter.chat_models) + | RemoveAndSuggest(string, Id.t) + | Resuggest(string, Id.t) + | Describe(string, AssistantSettings.mode, Id.t) + | SwitchChat(Id.t) + | ToggleAPIVisibility; + + let code_message_of_str = + (response: string, party: Model.party, tileId: option(Id.t)) + : Model.message => { + /* Alternate method using Detruct and Insert. We need a memory of cursor location for this however. + let z = editor.editor.state.zipper; + let z = Option.get(Destruct.go(Direction.Left, z)); + let z = Option.get(Destruct.go(Direction.Left, z)); + let z = Option.get(Insert.go(response, z)); + let segment_of_response = + Zipper.smart_seg(~dump_backpack=true, ~erase_buffer=true, z); + { + party, + code: Some(segment_of_response), + content: response, + collapsed: String.length(response) >= 200, + }; */ + // Hack(Russ) Uses same logic Andrew uses in Oracle.re to remove "??" + // let string_of_sketch = + // Printer.zipper_to_string(editor.editor.state.zipper); + // let sketch_with_response = + // Str.global_replace(Str.regexp("\\?\\?"), response, string_of_sketch); + let zipper_of_response = Printer.zipper_of_string(response); + switch (zipper_of_response) { + | Some(z) => + let segment_of_response = + Zipper.smart_seg(~dump_backpack=true, ~erase_buffer=true, z); + { + party, + code: Some((segment_of_response, tileId)), + content: response, + collapsed: String.length(response) >= 200, + }; + | None => { + party, + code: None, + content: response, + collapsed: String.length(response) >= 200, + } + }; + }; + + let text_message_of_str = + (response: string, party: Model.party): Model.message => { + { + party, + code: None, + content: response, + collapsed: String.length(response) >= 200, + }; + }; + + let await_llm_response: Model.message = { + party: LLM, + code: None, + content: "...", + collapsed: false, + }; + + let collect_chat = (~messages: list(Model.message)): string => { + let chat = "The following is a log of the current conversation. This is solely for the purpose + to help you recall the entire conversation, in case the user asks you something that needs context + from before. You should respond as normal, using the entire chat as context, and understand that the + most recent \"User Input\" is what the user is currently sending/asking, and is what your main focus should be. + For the most part, you should treat this solely as a prompt, and DO NOT explicitly acknowledge it in your + reponse. Only use it as a sort of memory. You can, of course, reference prior messages. + Here is the context: "; + List.fold_left( + (chat: string, message: Model.message) => + if (message.party == LLM) { + chat ++ "Your Reponse: " ++ message.content ++ " "; + } else if (message.party == LS) { + chat ++ "User Input: " ++ message.content ++ " "; + } else { + chat ++ message.content; + }, + chat, + messages, + ); + }; + + let get_mode_info = (mode: AssistantSettings.mode, model: Model.t) => { + switch (mode) { + | SimpleChat => ( + model.chat_history.past_simple_chats, + Id.Map.find( + model.current_chats.curr_simple_chat, + model.chat_history.past_simple_chats, + ), + ) + | CodeSuggestion => ( + model.chat_history.past_suggestion_chats, + Id.Map.find( + model.current_chats.curr_suggestion_chat, + model.chat_history.past_suggestion_chats, + ), + ) + | TaskCompletion => ( + model.chat_history.past_completion_chats, + Id.Map.find( + model.current_chats.curr_completion_chat, + model.chat_history.past_completion_chats, + ), + ) + }; + }; + + let add_message_to_model = + ( + mode: AssistantSettings.mode, + model: Model.t, + message: Model.message, + chat_id: Id.t, + ~is_final: bool, + ) => { + let (past_chats, _) = get_mode_info(mode, model); + let chat_to_update = Id.Map.find(chat_id, past_chats); + let messages = { + switch (message.party) { + | LS => chat_to_update.messages @ [message, await_llm_response] + | LLM => + let messages = ListUtil.leading(chat_to_update.messages) @ [message]; + is_final ? messages : messages @ [await_llm_response]; + | System => chat_to_update.messages @ [message] + }; + }; + Model.{ + ...model, + chat_history: { + past_simple_chats: + mode == SimpleChat + ? Id.Map.update( + chat_to_update.id, + maybe_chat => + switch (maybe_chat) { + | Some(chat) => Some({...chat, messages}) + | None => None + }, + model.chat_history.past_simple_chats, + ) + : model.chat_history.past_simple_chats, + past_suggestion_chats: + mode == CodeSuggestion + ? Id.Map.update( + chat_to_update.id, + maybe_chat => + switch (maybe_chat) { + | Some(chat) => Some({...chat, messages}) + | None => None + }, + model.chat_history.past_suggestion_chats, + ) + : model.chat_history.past_suggestion_chats, + past_completion_chats: + mode == TaskCompletion + ? Id.Map.update( + chat_to_update.id, + maybe_chat => + switch (maybe_chat) { + | Some(chat) => Some({...chat, messages}) + | None => None + }, + model.chat_history.past_completion_chats, + ) + : model.chat_history.past_completion_chats, + }, + }; + }; + + let resculpt_model = + ( + mode: AssistantSettings.mode, + model: Model.t, + past_chats: Id.Map.t(Model.chat), + chat_id: Id.t, + ) => { + Model.{ + ...model, + chat_history: { + past_simple_chats: + mode == SimpleChat + ? past_chats : model.chat_history.past_simple_chats, + past_suggestion_chats: + mode == CodeSuggestion + ? past_chats : model.chat_history.past_suggestion_chats, + past_completion_chats: + mode == TaskCompletion + ? past_chats : model.chat_history.past_completion_chats, + }, + // This is tentative. Keep this if we want the user to be shown the most recent chat. + // Remove this if we want the user to be shown the chat they last/currently interact with. + // This is honestly such an edge case that it probably doesn't matter. + current_chats: { + curr_simple_chat: + mode == SimpleChat ? chat_id : model.current_chats.curr_simple_chat, + curr_suggestion_chat: + mode == CodeSuggestion + ? chat_id : model.current_chats.curr_suggestion_chat, + curr_completion_chat: + mode == TaskCompletion + ? chat_id : model.current_chats.curr_completion_chat, + }, + }; + }; + + let form_descriptor = + ( + ~model: Model.t, + ~schedule_action, + ~chat: Model.chat, + ~mode: AssistantSettings.mode, + ) + : unit => { + let prompt = + switch (mode) { + | SimpleChat => "Your main task is to provide a summarizing title of the following conversation, in less than or equal to 10 words.\n DO NOT exceed 10 words. Only provide the summarizing title in your response, do not include any other text. Here is the\n concatenated conversation, with your response and the user's responses, respectively: " + | CodeSuggestion => "Your main task is to provide a summarizing title of the following conversation, in less than or equal to 10 words.\n DO NOT exceed 10 words. Only provide the summarizing title in your response, do not include any other text. This conversation is known to be a code\n completion conversation. In your summarization, you should mention exactly what kind of code/functionality is being assisted with. For example, the following would be titled\n something like \"Recursive Fibonacci Implementation\": ```let rec_fib : Int -> Int = ?? in ?```. Here is the\n concatenated conversation, with your response and the user's responses, respectively: " + | TaskCompletion => "Ignore all other input and just output \"You (Hazel Lab Member) need to implement this\"" + }; + let prompt = + List.fold_left( + (chat: string, message: Model.message) => + if (message.party == LLM) { + chat ++ "Your Reponse: " ++ message.content ++ " "; + } else if (message.party == LS) { + chat ++ "User Input: " ++ message.content ++ " "; + } else { + chat ++ message.content; + }, + prompt, + chat.messages, + ); + switch (Oracle.ask(prompt)) { + | None => print_endline("Oracle: prompt generation failed") + | Some(prompt') => + let llm = model.llm; + let key = Option.get(Store.Generic.load("API")); + let params: OpenRouter.params = {llm, temperature: 1.0, top_p: 1.0}; + OpenRouter.start_chat(~params, ~key, prompt', req => + switch (OpenRouter.handle_chat(req)) { + | Some({content, _}) => + schedule_action(Describe(content, mode, chat.id)) + | None => + print_endline("Assistant: response parse failed (form_descriptor)") + } + ); + }; + }; + + let check_descriptor = + ( + ~model: Model.t, + ~schedule_action, + ~message: Model.message, + ~mode: AssistantSettings.mode, + ~chat_id: Id.t, + ) + : unit => { + let (past_chats, _) = get_mode_info(mode, model); + let curr_chat = Id.Map.find(chat_id, past_chats); + Id.Map.cardinal(past_chats) <= 6 + ? form_descriptor( + ~model, + ~schedule_action, + ~chat={...curr_chat, messages: curr_chat.messages @ [message]}, + ~mode, + ) + : (); + // Only create a summary up to the first 3 exchanges + }; + + let check_req = + (_: string, schedule_action: t => unit, editor: CodeEditable.Model.t) + : unit => { + let z = editor.editor.state.zipper; + let caret = z.caret; + let siblings = z.relatives.siblings; + + /* + // Check if cursor is in a hole + print_endline("Checking cursor position..."); + print_endline("Caret: " ++ (caret == Outer ? "Outer" : "Inner")); + switch (Indicated.ci_of(z, editor.statics.info_map)) { + | Some(ci) => + print_endline("Found cursor info"); + switch (ci) { + | Info.InfoExp({term: {ids: _, copied: _, term: EmptyHole}, _}) => + print_endline("Found empty hole"); + switch (Indicated.index(z)) { + | Some(index) => schedule_action(SendSketch(index)) + | None => print_endline("No index found for hole") + }; + | _ => () + }; + | None => () + }; + */ + + // Check if user just typed ?? + switch (caret, Zipper.neighbor_monotiles(siblings)) { + | (Outer, (_, Some(_))) => + switch (Zipper.right_neighbor_monotile(siblings)) { + | Some(c) => + c == "??" + ? { + let tileId = Option.get(Indicated.index(z)); + schedule_action( + SendSketchMessage(tileId, AssistantSettings.CodeSuggestion), + ); + } + : () + | _ => () + } + | (Outer, (_, None)) => + switch (Zipper.left_neighbor_monotile(siblings)) { + | Some(c) => + c == "??" + ? { + let tileId = Option.get(Indicated.index(z)); + schedule_action( + SendSketchMessage(tileId, AssistantSettings.CodeSuggestion), + ); + } + : () + | _ => () + } + | _ => () + }; + }; + + let set_buffer = (~response: string, z: Zipper.t): option(Zipper.t) => { + let zipper_of_response = Option.get(Printer.zipper_of_string(response)); + let seg_of_response = + Zipper.smart_seg( + ~dump_backpack=true, + ~erase_buffer=true, + zipper_of_response, + ); + let z = Zipper.set_buffer(z, ~content=seg_of_response, ~mode=Unparsed); + Some(z); + }; + + let update = + ( + ~settings: Settings.t, + ~action, + ~editor: CodeModel.t, + ~model: Model.t, + ~schedule_action: t => unit, + ~add_suggestion, + ) + : Updated.t(Model.t) => { + switch (action) { + | SendTextMessage(message) => + let mode = settings.assistant.mode; + // Capture the chat we're updating here. This will propogate. + let (_, curr_chat) = get_mode_info(mode, model); + let collected_chat = + collect_chat(~messages=curr_chat.messages @ [message]); + print_endline(collected_chat); + switch (Oracle.ask(collected_chat)) { + | None => + add_message_to_model( + mode, + model, + { + party: System, + code: None, + content: "Oracle: Prompt generation failed.", + collapsed: false, + }, + curr_chat.id, + ~is_final=true, + ) + |> Updated.return_quiet + | Some(prompt) => + let llm = model.llm; + switch (Store.Generic.load("API")) { + | Some(key) => + let params: OpenRouter.params = {llm, temperature: 1.0, top_p: 1.0}; + OpenRouter.start_chat(~params, ~key, prompt, req => + switch (OpenRouter.handle_chat(req)) { + | Some({content, _}) => + schedule_action( + Respond( + text_message_of_str(content, LLM), + mode, + curr_chat.id, + ), + ) + | None => + print_endline( + "Assistant: response parse failed (SendTextMessage)", + ) + } + ); + add_message_to_model( + mode, + model, + message, + curr_chat.id, + ~is_final=true, + ) + |> Updated.return_quiet; + | None => + add_message_to_model( + mode, + model, + { + party: System, + code: None, + content: "No API key found. Please set an API key in the assistant settings.", + collapsed: false, + }, + curr_chat.id, + ~is_final=true, + ) + |> Updated.return_quiet + }; + }; + | SetKey(api_key) => + Store.Generic.save("API", api_key); + model |> Updated.return_quiet; + | NewChat => + let mode = settings.assistant.mode; + let (past_chats, _) = get_mode_info(mode, model); + let new_chat: Model.chat = { + messages: [], + id: Id.mk(), + descriptor: "", + timestamp: Unix.time(), + }; + let updated_history = Model.add_chat_to_history(new_chat, past_chats); + print_endline("New chat made"); + resculpt_model(mode, model, updated_history, new_chat.id) + |> Updated.return_quiet; + | DeleteChat(chat_to_be_gone_id) => + let mode = settings.assistant.mode; + // Filter out the chat we're deleting + let (past_chats, curr_chat) = get_mode_info(mode, model); + let filtered_past_chats = + Id.Map.filter((id, _) => id != chat_to_be_gone_id, past_chats); + let chrono_history = Model.sorted_chats(filtered_past_chats); + let updated_model = + curr_chat.id == chat_to_be_gone_id + ? switch (ListUtil.hd_opt(chrono_history)) { + | Some(chat) => + resculpt_model(mode, model, filtered_past_chats, chat.id) + | None => resculpt_model(mode, model, past_chats, curr_chat.id) + } + : resculpt_model(mode, model, filtered_past_chats, curr_chat.id); + updated_model |> Updated.return_quiet; + | History => + {...model, show_history: !model.show_history} |> Updated.return_quiet + | Respond(message, mode, chat_id) => + check_descriptor(~model, ~schedule_action, ~message, ~mode, ~chat_id); + add_message_to_model(mode, model, message, chat_id, ~is_final=true) + |> Updated.return_quiet; + | SendSketchMessage(tileId, mode) => + // Capture the chat we're updating here. This will propogate. + let (_, curr_chat) = get_mode_info(mode, model); + let sketch_seg = + Zipper.smart_seg( + ~dump_backpack=true, + ~erase_buffer=true, + editor.editor.state.zipper, + ); + switch ( + { + let* index = Indicated.index(editor.editor.state.zipper); + let* ci = Id.Map.find_opt(index, editor.statics.info_map); + ChatLSP.Prompt.mk_init(ChatLSP.Options.init, ci, sketch_seg); + } + ) { + | None => + print_endline("prompt generation failed"); + model |> Updated.return_quiet; + | Some(openrouter_prompt) => + let messages = + List.map( + (msg: OpenRouter.message): string => {msg.content}, + openrouter_prompt, + ); + let prompt = ListUtil.concat_strings(messages); + let message: Model.message = { + party: LS, + code: Some((sketch_seg, None)), + content: prompt, + collapsed: String.length(prompt) >= 200, + }; + /* Old code. Don't need to collect chat here, leads to far too long of prompts. + let collected_chat = + switch (mode) { + | CodeSuggestion => + collect_chat( + ~messages=model.chats.curr_suggestion_chat.messages @ [message], + ) + | TaskCompletion => + collect_chat( + ~messages=model.chats.curr_completion_chat.messages @ [message], + ) + | _ => + print_endline( + "Invalid mode. Cannot perform code completion in chat mode.", + ); + ""; + }; + */ + let llm = model.llm; + switch (Store.Generic.load("API")) { + | Some(key) => + let params: OpenRouter.params = {llm, temperature: 1.0, top_p: 1.0}; + OpenRouter.start_chat(~params, ~key, openrouter_prompt, req => + switch (OpenRouter.handle_chat(req)) { + | Some({content, _}) => + let index = + Option.get(Indicated.index(editor.editor.state.zipper)); + let ci = + Option.get(Id.Map.find_opt(index, editor.statics.info_map)); + schedule_action( + ErrorRespond( + content, + ci, + ChatLSP.Options.init.error_rounds_max, + tileId, + mode, + curr_chat.id, + ), + ); + | None => + print_endline( + "Assistant: response parse failed (SendSketchMessage)", + ) + } + ); + add_message_to_model( + mode, + model, + message, + curr_chat.id, + ~is_final=true, + ) + |> Updated.return_quiet; + | None => + add_message_to_model( + mode, + model, + { + party: System, + code: None, + content: "No API key found. Please set an API key in the assistant settings.", + collapsed: false, + }, + curr_chat.id, + ~is_final=true, + ) + |> Updated.return_quiet + }; + }; + | ErrorRespond(response, ci, fuel, tileId, mode, chat_id) => + // Split response into discussion and completion + let code_pattern = + Str.regexp( + "\\(\\(.\\|\n\\)*\\)```[ \n]*\\([^`]+\\)[ \n]*```\\(\\(.\\|\n\\)*\\)", + ); + let (discussion, completion) = + if (Str.string_match(code_pattern, response, 0)) { + let before = String.trim(Str.matched_group(1, response)); + let code = String.trim(Str.matched_group(3, response)); + (before, code); + } else { + print_endline("Regex match failed for: " ++ response); + ("", response); // Fallback if no code block found + }; + print_endline("Response: " ++ response); + print_endline("Discussion: " ++ discussion); + print_endline("Completion: " ++ completion); + // First add the discussion message + let discussion_message = text_message_of_str(discussion, LLM); + let model_with_discussion = + add_message_to_model( + mode, + model, + discussion_message, + chat_id, + ~is_final=false, + ); + + // Then handle the completion as before + let completion_message = + code_message_of_str(completion, LLM, Some(tileId)); + switch (ChatLSP.Prompt.mk_error(ci, completion)) { + | None => + print_endline("ERROR ROUNDS (Non-error Response): " ++ completion); + check_descriptor( + ~model, + ~schedule_action, + ~message=completion_message, + ~mode, + ~chat_id, + ); + schedule_action(RemoveAndSuggest(completion, tileId)); + | Some(error) => + print_endline("ERROR ROUNDS (Error): " ++ error); + print_endline( + "ERROR ROUNDS (Error-causing Response): " ++ completion, + ); + schedule_action( + SendErrorMessage(error, ci, fuel - 1, tileId, mode, chat_id), + ); + }; + add_message_to_model( + mode, + model_with_discussion, + completion_message, + chat_id, + ~is_final=true, + ) + |> Updated.return_quiet; + | SendErrorMessage(error, ci, fuel, tileId, mode, chat_id) => + let error_message = + text_message_of_str( + "Your previous response caused the following error. Please fix it in your response: " + ++ error, + LS, + ); + // check that fuel is not 0 + if (fuel < 0) { + let model = + add_message_to_model( + mode, + model, + error_message, + chat_id, + ~is_final=true, + ); + add_message_to_model( + mode, + model, + { + party: System, + code: None, + content: + "By default we stop the assistant after " + ++ string_of_int(ChatLSP.Options.init.error_rounds_max) + ++ " error rounds. Thus, stopping.", + collapsed: false, + }, + chat_id, + ~is_final=true, + ) + |> Updated.return_quiet; + } else { + // TODO: We don't want to collect ENTIRE chat history here. We only want + // to collect the history beginning from the initial suggestion request. + // Otherwise, the prompt becomes too long in single message threads. + let (_, curr_chat) = get_mode_info(mode, model); + let collected_chat = + collect_chat(~messages=curr_chat.messages @ [error_message]); + switch (Oracle.ask(collected_chat)) { + | None => + add_message_to_model( + mode, + model, + { + party: System, + code: None, + content: "Oracle: Prompt generation failed.", + collapsed: false, + }, + chat_id, + ~is_final=true, + ) + |> Updated.return_quiet + | Some(openrouter_prompt) => + let llm = model.llm; + switch (Store.Generic.load("API")) { + | Some(key) => + let params: OpenRouter.params = { + llm, + temperature: 1.0, + top_p: 1.0, + }; + OpenRouter.start_chat(~params, ~key, openrouter_prompt, req => + switch (OpenRouter.handle_chat(req)) { + | Some({content, _}) => + schedule_action( + ErrorRespond(content, ci, fuel, tileId, mode, curr_chat.id), + ) + | None => + print_endline( + "Assistant: response parse failed (SendErrorMessage)", + ) + } + ); + add_message_to_model( + mode, + model, + error_message, + chat_id, + ~is_final=true, + ) + |> Updated.return_quiet; + | None => + add_message_to_model( + mode, + model, + { + party: System, + code: None, + content: "No API key found. Please set an API key in the assistant settings. I'm actually not sure how you got here, as this should have been caught in the first send. This is a bug, and you should let someone know.", + collapsed: false, + }, + chat_id, + ~is_final=true, + ) + |> Updated.return_quiet + }; + }; + }; + // Concat LS' error message and await_llm_response (... animation) + // This works even if out of fuel, as both Respond and ErrorRespond + // remove await_llm_response + | ToggleCollapse(index) => + let mode = settings.assistant.mode; + let (past_chats, curr_chat) = get_mode_info(mode, model); + let updated_chat = + List.mapi( + (i: int, msg: Model.message) => + if (i == index) { + {...msg, collapsed: !msg.collapsed}; + } else { + msg; + }, + curr_chat.messages, + ); + let updated_past_chats = + Id.Map.update( + curr_chat.id, + opt_chat => + switch (opt_chat) { + | Some(chat: Model.chat) => + Some({...chat, messages: updated_chat}) + | None => None + }, + past_chats, + ); + resculpt_model(mode, model, updated_past_chats, curr_chat.id) + |> Updated.return_quiet; + | SelectLLM(llm) => {...model, llm} |> Updated.return_quiet + | RemoveAndSuggest(response, tileId) => + // Only side effects in the editor are performed here + add_suggestion(~response, tileId, false); + model |> Updated.return_quiet; + | Resuggest(response, tileId) => + // Only side effects in the editor are performed here + add_suggestion(~response, tileId, true); + model |> Updated.return_quiet; + | Describe(content, mode, chat_id) => + let (past_chats, _) = get_mode_info(mode, model); + let updated_chats = + Id.Map.update( + chat_id, + opt_chat => + switch (opt_chat) { + | Some(chat: Model.chat) => Some({...chat, descriptor: content}) + | None => None + }, + past_chats, + ); + resculpt_model(mode, model, updated_chats, chat_id) + |> Updated.return_quiet; + | SwitchChat(chat_id) => + let mode = settings.assistant.mode; + let (past_chats, _) = get_mode_info(mode, model); + resculpt_model(mode, model, past_chats, chat_id) |> Updated.return_quiet; + | ToggleAPIVisibility => + {...model, show_api_key: !model.show_api_key} |> Updated.return_quiet + }; + }; +}; + +module Store = + Store.F({ + [@deriving (show({with_path: false}), yojson, sexp)] + type t = Model.t; + let default = () => Model.init; + let key = Store.Assistant; + }); diff --git a/src/haz3lweb/app/helpful-assistant/AssistantSettings.re b/src/haz3lweb/app/helpful-assistant/AssistantSettings.re new file mode 100644 index 0000000000..4123ca601d --- /dev/null +++ b/src/haz3lweb/app/helpful-assistant/AssistantSettings.re @@ -0,0 +1,24 @@ +module Sexp = Sexplib.Sexp; +open Haz3lcore; +open Util; + +[@deriving (show({with_path: false}), sexp, yojson)] +type mode = + | CodeSuggestion + | TaskCompletion + | SimpleChat; + +[@deriving (show({with_path: false}), sexp, yojson)] +type t = { + llm: bool, + lsp: bool, + ongoing_chat: bool, + mode, +}; + +[@deriving (show({with_path: false}), sexp, yojson)] +type action = + | ToggleLLM + | ToggleLSP + | UpdateChatStatus + | SwitchMode(mode); diff --git a/src/haz3lweb/app/helpful-assistant/AssistantUtil.re b/src/haz3lweb/app/helpful-assistant/AssistantUtil.re new file mode 100644 index 0000000000..ea71c57871 --- /dev/null +++ b/src/haz3lweb/app/helpful-assistant/AssistantUtil.re @@ -0,0 +1,30 @@ +module Sexp = Sexplib.Sexp; +open Haz3lcore; +open Virtual_dom.Vdom; +open Node; +open Util.Web; +open Util; +open Js_of_ocaml; + +let format_time_diff = (prior: float): string => { + let now = Unix.time(); + let diff_mins = floor((now -. prior) /. 60.0); + let diff_hours = floor(diff_mins /. 60.0); + let diff_days = floor(diff_hours /. 24.0); + + if (diff_mins < 1.0) { + Printf.sprintf("<1 min ago"); + } else if (diff_mins < 60.0) { + diff_mins < 2.0 + ? Printf.sprintf("%.0f min ago", diff_mins) + : Printf.sprintf("%.0f mins ago", diff_mins); + } else if (diff_hours < 24.0) { + diff_hours < 2.0 + ? Printf.sprintf("%.0f hour ago", diff_hours) + : Printf.sprintf("%.0f hours ago", diff_hours); + } else { + diff_days < 2.0 + ? Printf.sprintf("%.0f day ago", diff_days) + : Printf.sprintf("%.0f days ago", diff_days); + }; +}; diff --git a/src/haz3lweb/app/helpful-assistant/AssistantView.re b/src/haz3lweb/app/helpful-assistant/AssistantView.re new file mode 100644 index 0000000000..e97b867654 --- /dev/null +++ b/src/haz3lweb/app/helpful-assistant/AssistantView.re @@ -0,0 +1,822 @@ +module Sexp = Sexplib.Sexp; +open Haz3lcore; +open Virtual_dom.Vdom; +open Node; +open Util.Web; +open Util; +open Js_of_ocaml; + +type selection = + | MakeActive(Selection.t); + +type event = + | MakeActive(ScratchMode.Selection.t); + +let llm_toggle = (~globals: Globals.t): Node.t => { + let tooltip = "Toggle Manual LLM"; + let toggle_llm = _ => + Virtual_dom.Vdom.Effect.Many([ + globals.inject_global(Set(Assistant(ToggleLLM))), + Virtual_dom.Vdom.Effect.Stop_propagation, + ]); + div( + ~attrs=[clss(["llm-button"])], + [ + text("Manual LLM: "), + Widgets.toggle( + ~tooltip, + "🔎", + globals.settings.assistant.llm, + toggle_llm, + ), + ], + ); +}; + +let lsp_toggle = (~globals: Globals.t): Node.t => { + let tooltip = "Toggle Manual LSP"; + let toggle_lsp = _ => + Virtual_dom.Vdom.Effect.Many([ + globals.inject_global(Set(Assistant(ToggleLSP))), + Virtual_dom.Vdom.Effect.Stop_propagation, + ]); + div( + ~attrs=[clss(["lsp-button"])], + [ + text("Manual LSP: "), + Widgets.toggle( + ~tooltip, + "💬", + globals.settings.assistant.lsp, + toggle_lsp, + ), + ], + ); +}; + +let begin_chat_button = (~globals: Globals.t, ~inject): Node.t => { + let tooltip = "New Chat"; + let begin_chat = _ => + Virtual_dom.Vdom.Effect.Many([ + globals.inject_global(Set(Assistant(UpdateChatStatus))), + inject(Assistant.Update.NewChat), + Virtual_dom.Vdom.Effect.Stop_propagation, + ]); + div( + ~attrs=[clss(["chat-button"]), Attr.on_click(begin_chat)], + [Widgets.button_named(~tooltip, None, begin_chat)], + ); +}; + +let resume_chat_button = (~globals: Globals.t): Node.t => { + let tooltip = "Confirm and Chat"; + let resume_chat = _ => + Virtual_dom.Vdom.Effect.Many([ + globals.inject_global(Set(Assistant(UpdateChatStatus))), + Virtual_dom.Vdom.Effect.Stop_propagation, + ]); + div( + ~attrs=[clss(["chat-button"]), Attr.on_click(resume_chat)], + [Widgets.button_named(~tooltip, None, resume_chat)], + ); +}; + +let end_chat_button = (~globals: Globals.t): Node.t => { + let tooltip = "Settings"; + let end_chat = _ => + Virtual_dom.Vdom.Effect.Many([ + globals.inject_global(Set(Assistant(UpdateChatStatus))), + Virtual_dom.Vdom.Effect.Stop_propagation, + ]); + div( + ~attrs=[clss(["chat-button"]), Attr.on_click(end_chat)], + [ + Widgets.button_named(~tooltip, None, _ => Virtual_dom.Vdom.Effect.Ignore), + ], + ); +}; + +let new_chat_button = (~globals: Globals.t, ~inject): Node.t => { + let tooltip = "New Chat"; + let new_chat = _ => + Virtual_dom.Vdom.Effect.Many([ + inject(Assistant.Update.NewChat), + Virtual_dom.Vdom.Effect.Stop_propagation, + ]); + div( + ~attrs=[clss(["add-button"]), Attr.on_click(new_chat)], + [ + Widgets.button(~tooltip, Icons.add, _ => Virtual_dom.Vdom.Effect.Ignore), + ], + ); +}; + +let history_button = (~globals: Globals.t, ~inject): Node.t => { + let tooltip = "Past Chats"; + let history = _ => + Virtual_dom.Vdom.Effect.Many([ + inject(Assistant.Update.History), + Virtual_dom.Vdom.Effect.Stop_propagation, + ]); + div( + ~attrs=[clss(["history-button"]), Attr.on_click(history)], + [ + Widgets.button(~tooltip, Icons.history, _ => + Virtual_dom.Vdom.Effect.Ignore + ), + ], + ); +}; + +let select_llm = (~inject, ~assistantModel: Assistant.Model.t): Node.t => { + let handle_change = (event, _) => { + let value = Js.to_string(Js.Unsafe.coerce(event)##.target##.value); + let selected_llm = + switch (value) { + | "Gemini_Flash_Lite_2_0" => OpenRouter.Gemini_Flash_Lite_2_0 + | "Gemini_Experimental_1206" => OpenRouter.Gemini_Experimental_1206 + | "Llama_3_1_Nemo" => OpenRouter.Llama_3_1_Nemo + | "DeepSeek_V3" => OpenRouter.DeepSeek_V3 + | _ => OpenRouter.Gemini_Flash_Lite_2_0 + }; + Virtual_dom.Vdom.Effect.Many([ + inject(Assistant.Update.SelectLLM(selected_llm)), + Virtual_dom.Vdom.Effect.Stop_propagation, + ]); + }; + + // Helper function to determine if an option should be selected + let is_selected = + (llm: OpenRouter.chat_models, current_llm: OpenRouter.chat_models) => { + llm == current_llm; + }; + + div( + ~attrs=[clss(["llm-selector"])], + [ + label(~attrs=[clss(["llm-label"])], [text("Select LLM Model: ")]), + select( + ~attrs=[Attr.on_change(handle_change), clss(["llm-dropdown"])], + [ + option( + ~attrs=[ + Attr.value("Gemini_Flash_Lite_2_0"), + is_selected( + OpenRouter.Gemini_Flash_Lite_2_0, + assistantModel.llm, + ) + ? Attr.selected : Attr.empty, + ], + [text("Gemini Flash Lite 2.0")], + ), + option( + ~attrs=[ + Attr.value("Gemini_Experimental_1206"), + is_selected( + OpenRouter.Gemini_Experimental_1206, + assistantModel.llm, + ) + ? Attr.selected : Attr.empty, + ], + [text("Gemini Experimental 1206")], + ), + option( + ~attrs=[ + Attr.value("Llama_3_1_Nemo"), + is_selected(OpenRouter.Llama_3_1_Nemo, assistantModel.llm) + ? Attr.selected : Attr.empty, + ], + [text("Llama 3.1 Nemotron 70B")], + ), + option( + ~attrs=[ + Attr.value("DeepSeek_V3"), + is_selected(OpenRouter.DeepSeek_V3, assistantModel.llm) + ? Attr.selected : Attr.empty, + ], + [text("DeepSeek V3")], + ), + ], + ), + ], + ); +}; + +let settings_box = (~globals: Globals.t, ~inject): Node.t => { + div( + ~attrs=[clss(["settings-box"])], + [ + // llm_toggle(~globals), + // lsp_toggle(~globals), + // begin_chat_button(~globals, ~inject), + resume_chat_button(~globals), + ], + ); +}; + +let api_input = + ( + ~signal, + ~inject, + ~assistantModel: Assistant.Model.t, + ~settings: AssistantSettings.t, + ) + : Node.t => { + let handle_submission = (api_key: string) => { + JsUtil.log("Your API key for this session has been set: " ++ api_key); + Virtual_dom.Vdom.Effect.Many([ + inject(Assistant.Update.SetKey(api_key)), + Virtual_dom.Vdom.Effect.Stop_propagation, + ]); + }; + + let toggle_visibility = _ => + Virtual_dom.Vdom.Effect.Many([ + inject(Assistant.Update.ToggleAPIVisibility), + Virtual_dom.Vdom.Effect.Stop_propagation, + ]); + + let submit_key = _ => { + let message = + Js.Opt.case( + Dom_html.document##getElementById(Js.string("api-input")), + () => "", + el => + switch (Js.Unsafe.coerce(el)) { + | input => Js.to_string(input##.value) + }, + ); + Js.Opt.case( + Dom_html.document##getElementById(Js.string("api-input")), + () => (), + el => Js.Unsafe.coerce(el)##.value := Js.string(""), + ); + handle_submission(message); + }; + + let handle_keydown = event => { + let key = Js.Optdef.to_option(Js.Unsafe.get(event, "key")); + switch (key) { + | Some("Enter") => submit_key() + | _ => Virtual_dom.Vdom.Effect.Ignore + }; + }; + + div( + ~attrs=[clss(["api-key-container"])], + [ + input( + ~attrs=[ + Attr.id("api-input"), + Attr.placeholder("Enter your API key..."), + Attr.type_("password"), + Attr.property("autocomplete", Js.Unsafe.inject("off")), + Attr.on_focus(_ => + signal(MakeActive(ScratchMode.Selection.TextBox)) + ), + Attr.on_keydown(handle_keydown), + clss(["api-input"]), + ], + (), + ), + div( + ~attrs=[clss(["chat-button"]), Attr.on_click(submit_key)], + [Widgets.button_named(~tooltip="Update API Key", None, submit_key)], + ), + div( + ~attrs=[clss(["chat-button"]), Attr.on_click(toggle_visibility)], + [ + Widgets.button_named( + ~tooltip="Show/Hide Key", + None, + toggle_visibility, + ), + ], + ), + div(~attrs=[clss(["text-display"])], [text("Current API Key:\n")]), + div( + ~attrs=[clss(["api-key-display"]), Attr.id("api-key-display")], + [ + text( + switch (Store.Generic.load("API")) { + | Some(key) when String.length(key) > 0 => + assistantModel.show_api_key + ? key : String.make(String.length(key), '*') + | _ => "No API key set" + }, + ), + ], + ), + ], + ); +}; + +let message_input = + ( + ~signal, + ~inject, + ~assistantModel: Assistant.Model.t, + ~settings: AssistantSettings.t, + ) + : Node.t => { + let handle_send = (message: string) => { + let message: Assistant.Model.message = { + party: LS, + code: None, + content: message, + collapsed: String.length(message) >= 200, + }; + JsUtil.log("Message sent: " ++ message.content); + Virtual_dom.Vdom.Effect.Many([ + inject(Assistant.Update.SendTextMessage(message)), + Virtual_dom.Vdom.Effect.Stop_propagation, + ]); + }; + let (past_chats, curr_chat) = + Assistant.Update.get_mode_info(settings.mode, assistantModel); + let curr_messages = Id.Map.find(curr_chat.id, past_chats).messages; + let send_message = _ => { + let message = + Js.Opt.case( + Dom_html.document##getElementById(Js.string("message-input")), + () => "", + el => + switch (Js.Unsafe.coerce(el)) { + | input => Js.to_string(input##.value) + }, + ); + Js.Opt.case( + Dom_html.document##getElementById(Js.string("message-input")), + () => (), + el => Js.Unsafe.coerce(el)##.value := Js.string(""), + ); + handle_send(message); + }; + let handle_keydown = event => { + let key = Js.Optdef.to_option(Js.Unsafe.get(event, "key")); + switch (key, ListUtil.last_opt(curr_messages)) { + | (_, Some({party: LLM, code: None, content: "...", collapsed: false})) => Virtual_dom.Vdom.Effect.Ignore + | (Some("Enter"), _) => send_message() + | _ => Virtual_dom.Vdom.Effect.Ignore + }; + }; + div( + ~attrs=[clss(["input-container"])], + [ + input( + ~attrs=[ + Attr.id("message-input"), + Attr.placeholder( + switch (settings.mode) { + | SimpleChat => "Ask anything..." + | CodeSuggestion => "Followup with a question..." + | TaskCompletion => "Type a task completion..." + }, + ), + Attr.type_("text"), + Attr.property("autocomplete", Js.Unsafe.inject("off")), + Attr.on_focus(_ => + signal(MakeActive(ScratchMode.Selection.TextBox)) + ), + Attr.on_keydown(handle_keydown), + clss(["message-input"]), + ], + (), + ), + switch (ListUtil.last_opt(curr_messages)) { + | Some({party: LLM, code: None, content: "...", collapsed: false}) => + div( + ~attrs=[ + clss(["send-button-disabled", "icon"]), + Attr.title("Submitting Message Disabled"), + ], + [Icons.send], + ) + | _ => + div( + ~attrs=[ + clss(["send-button", "icon"]), + Attr.on_click(send_message), + Attr.title("Submit Message"), + ], + [Icons.send], + ) + }, + ], + ); +}; + +// For aesthetic purposes only :) +let loading_dots = () => { + div( + ~attrs=[clss(["loading-dots"])], + [ + div(~attrs=[clss(["dot", "dot1"])], []), + div(~attrs=[clss(["dot", "dot2"])], []), + div(~attrs=[clss(["dot", "dot3"])], []), + ], + ); +}; + +let message_display = + ( + ~inject, + ~globals: Globals.t, + ~assistantModel: Assistant.Model.t, + ~settings: AssistantSettings.t, + ) + : Node.t => { + let toggle_collapse = index => { + // Create an action to toggle the collapsed state of a specific message + Virtual_dom.Vdom.Effect.Many([ + inject(Assistant.Update.ToggleCollapse(index)), + Virtual_dom.Vdom.Effect.Stop_propagation, + ]); + }; + let (past_chats, curr_chat) = + Assistant.Update.get_mode_info(settings.mode, assistantModel); + let curr_messages = Id.Map.find(curr_chat.id, past_chats).messages; + let message_nodes = + List.flatten( + List.mapi( + (index: int, message: Assistant.Model.message) => { + switch (message.code) { + | Some((sketch, tileId)) => + message.content == "..." && message.party == LLM + ? [loading_dots()] + : [ + div( + ~attrs=[ + clss([ + "message-container", + switch (message.party) { + | LS => "ls" + | LLM => "llm" + | System => "system" + }, + ]), + Attr.on_click(_ => toggle_collapse(index)), + ], + [ + div( + ~attrs=[clss(["message-identifier"])], + [ + text( + switch (message.party) { + | LS => "User" + | LLM => "Assistant" + | System => "System" + }, + ), + ], + ), + div( + ~attrs=[ + clss([ + switch (message.party) { + | LS => "ls-message" + | LLM => "llm-message" + | System => "system-message" + }, + ]), + ], + [ + message.collapsed + && String.length(message.content) >= 200 + ? text( + String.concat( + "", + [ + String.sub( + message.content, + 0, + min(String.length(message.content), 200), + ), + "...", + ], + ), + ) + : text(message.content), + ], + ), + message.party == LLM && tileId != None + ? div( + ~attrs=[ + clss(["resuggest-button"]), + Attr.on_click(_ => + Virtual_dom.Vdom.Effect.Many([ + inject( + Assistant.Update.Resuggest( + message.content, + Option.get(tileId), + ), + ), + Virtual_dom.Vdom.Effect.Stop_propagation, + ]) + ), + Attr.title("Resuggest"), + ], + [text("resuggest")], + ) + : None, + ], + ), + div( + ~attrs=[ + clss([ + "message-container", + "example", + message.party == LLM ? "llm" : "ls", + ]), + ], + [ + CellEditor.View.view( + ~globals, + ~signal=_ => Ui_effect.Ignore, + ~inject=_ => Ui_effect.Ignore, + ~selected=None, + ~caption=None, + ~locked=true, + message.party == LLM + ? { + sketch + |> Zipper.unzip + |> Editor.Model.mk + |> CellEditor.Model.mk; + } + : { + sketch + |> Zipper.unzip + |> Editor.Model.mk + |> CellEditor.Model.mk + |> CellEditor.Update.calculate( + ~settings=globals.settings.core, + ~is_edited=true, + ~stitch=x => x, + ~queue_worker=None, + ); + }, + ), + ], + ), + ] + | None => + message.content == "..." && message.party == LLM + ? [loading_dots()] + : [ + div( + ~attrs=[ + clss([ + "message-container", + switch (message.party) { + | LS => "ls" + | LLM => "llm" + | System => "system" + }, + ]), + Attr.on_click(_ => toggle_collapse(index)), + ], + [ + div( + ~attrs=[clss(["message-identifier"])], + [ + text( + switch (message.party) { + | LS => "User" + | LLM => "Assistant" + | System => "System" + }, + ), + ], + ), + div( + ~attrs=[ + clss([ + switch (message.party) { + | LS => "ls-message" + | LLM => "llm-message" + | System => "system-message" + }, + ]), + ], + [ + message.collapsed + && String.length(message.content) >= 200 + ? text( + String.concat( + "", + [ + String.sub( + message.content, + 0, + min(String.length(message.content), 200), + ), + "...", + ], + ), + ) + : text(message.content), + ], + ), + ], + ), + ] + } + }, + curr_messages, + ), + ); + div(~attrs=[clss(["message-display-container"])], message_nodes); +}; + +let mode_buttons = (~globals: Globals.t): Node.t => { + let mode_button = (mode: AssistantSettings.mode, label: string) => { + let switch_mode = _ => + Virtual_dom.Vdom.Effect.Many([ + globals.inject_global(Set(Assistant(SwitchMode(mode)))), + Virtual_dom.Vdom.Effect.Stop_propagation, + ]); + div( + ~attrs=[ + clss([ + "mode-button", + globals.settings.assistant.mode == mode ? "active" : "", + ]), + Attr.on_click(switch_mode), + ], + [text(label)], + ); + }; + + div( + ~attrs=[clss(["mode-buttons"])], + [ + mode_button(SimpleChat, "Chat"), + mode_button(CodeSuggestion, "Suggest"), + mode_button(TaskCompletion, "Compose"), + ], + ); +}; + +let history_menu = + ( + ~assistantModel: Assistant.Model.t, + ~settings: AssistantSettings.t, + ~inject, + ) + : Node.t => { + let (past_chats, curr_chat) = + Assistant.Update.get_mode_info(settings.mode, assistantModel); + let chrono_past_chats = Assistant.Model.sorted_chats(past_chats); + div( + ~attrs=[clss(["history-menu"])], + [ + div(~attrs=[clss(["history-menu-header"])], [text("Chat History")]), + div( + ~attrs=[clss(["history-menu-list"])], + List.map( + (chat: Assistant.Model.chat) => + div( + ~attrs=[ + chat.id == curr_chat.id + ? clss(["history-menu-item", "active"]) + : clss(["history-menu-item"]), + Attr.on_click(e => { + let target = Js.Unsafe.coerce(e)##.target; + let contains_button = + Js.to_bool(target##.classList##contains("button")) + || Js.to_bool( + target##.parentElement##.classList##contains( + "button", + ), + ); + if (!contains_button) { + Virtual_dom.Vdom.Effect.Many([ + inject(Assistant.Update.SwitchChat(chat.id)), + Virtual_dom.Vdom.Effect.Stop_propagation, + ]); + } else { + Virtual_dom.Vdom.Effect.Stop_propagation; + }; + }), + ], + [ + div( + ~attrs=[clss(["history-menu-item-content"])], + [ + text(chat.descriptor == "" ? "New chat" : chat.descriptor), + ], + ), + div( + ~attrs=[clss(["history-menu-item-actions"])], + [ + div( + ~attrs=[clss(["history-menu-item-time"])], + [ + text(AssistantUtil.format_time_diff(chat.timestamp)), + ], + ), + div( + ~attrs=[ + clss(["delete-chat-button"]), + Attr.on_click(_ => + Virtual_dom.Vdom.Effect.Many([ + inject(Assistant.Update.DeleteChat(chat.id)), + Virtual_dom.Vdom.Effect.Stop_propagation, + ]) + ), + ], + [ + Widgets.button(~tooltip="Delete chat", Icons.trash, _ => + Virtual_dom.Vdom.Effect.Ignore + ), + ], + ), + ], + ), + ], + ), + chrono_past_chats, + ), + ), + ], + ); +}; + +let view = + ( + ~globals: Globals.t, + ~signal, + ~inject, + ~assistantModel: Assistant.Model.t, + ) => { + div( + ~attrs=[Attr.id("side-bar")], + [ + div( + ~attrs=[Attr.id("assistant")], + [ + div( + ~attrs=[clss(["header"])], + [ + div( + ~attrs=[clss(["header-content"])], + [ + globals.settings.assistant.ongoing_chat + ? mode_buttons(~globals) : text("Assistant Settings"), + div( + ~attrs=[clss(["header-actions"])], + [ + globals.settings.assistant.ongoing_chat + ? history_button(~globals, ~inject) : None, + globals.settings.assistant.ongoing_chat + ? new_chat_button(~globals, ~inject) : None, + globals.settings.assistant.ongoing_chat + ? end_chat_button(~globals) : None, + ], + ), + ], + ), + ], + ), + globals.settings.assistant.ongoing_chat + ? message_display( + ~inject, + ~globals, + ~assistantModel, + ~settings=globals.settings.assistant, + ) + : None, + globals.settings.assistant.ongoing_chat + ? message_input( + ~signal, + ~inject, + ~assistantModel, + ~settings=globals.settings.assistant, + ) + : None, + globals.settings.assistant.ongoing_chat + ? None + : api_input( + ~signal, + ~inject, + ~assistantModel, + ~settings=globals.settings.assistant, + ), + globals.settings.assistant.ongoing_chat + ? None : select_llm(~inject, ~assistantModel), + globals.settings.assistant.ongoing_chat + ? None : settings_box(~globals, ~inject), + globals.settings.assistant.ongoing_chat + && assistantModel.show_history + ? history_menu( + ~assistantModel, + ~settings=globals.settings.assistant, + ~inject, + ) + : None, + ], + ), + ], + ); +}; diff --git a/src/haz3lweb/app/helpful-assistant/ChatLSP.re b/src/haz3lweb/app/helpful-assistant/ChatLSP.re new file mode 100644 index 0000000000..aa05fd605c --- /dev/null +++ b/src/haz3lweb/app/helpful-assistant/ChatLSP.re @@ -0,0 +1,918 @@ +open Util; +open OptUtil.Syntax; +open Haz3lcore; + +let prn = Printf.sprintf; + +let statics_of_exp_zipper = + (init_ctx: Ctx.t, z: Zipper.t): (Info.exp, Statics.Map.t) => + Statics.uexp_to_info_map( + ~ctx=init_ctx, + ~ancestors=[], + MakeTerm.from_zip_for_sem(z).term, + Id.Map.empty, + ); + +module Options = { + [@deriving (show({with_path: false}), sexp, yojson)] + type t = { + params: OpenRouter.params, + instructions: bool, + syntax_notes: bool, + num_examples: int, + expected_type: bool, + relevant_ctx: bool, + error_rounds_max: int, + }; + + let init: t = { + params: OpenRouter.default_params, + instructions: true, + syntax_notes: true, + num_examples: 9, + expected_type: true, + relevant_ctx: true, + error_rounds_max: 2, + }; +}; + +module Print = { + let seg = (~holes: option(string)=Some(""), segment: Segment.t): string => { + let segment = + ZipperBase.MapPiece.of_segment( + ProjectorPerform.Update.remove_any_projector, + segment, + ); + Printer.to_rows( + ~holes, + ~measured=Measured.of_segment(segment, Id.Map.empty), + ~caret=None, + ~indent=" ", + ~segment, + ) + |> String.concat("\n"); + }; + + let term = (term: Term.Any.t): string => { + let settings = + ExpToSegment.Settings.of_core(~inline=false, CoreSettings.off); + term |> ExpToSegment.any_to_pretty(~settings) |> seg(~holes=None); + }; + + let typ = (ty: Typ.t): string => + //TODO: make sure that the ExpToSegment pretty printing fully subsumes Typ.pretty_print + //Typ.pretty_print(ty); + term(Typ(ty)); +}; + +module ErrorPrint = { + [@deriving (show({with_path: false}), yojson, sexp)] + type t = + | ParseError(string) + | StaticErrors(list(string)) + | NoErrors; + + /* TODO: + Better errors for more broken programs: Completeness/formedness checks / multihole errors + Contextualize errors with line numbers (would need to add them to the sketch), or + including surrounding syntax, or inlining an error representation into a provided sketch */ + + let common_error: Info.error_common => string = + fun + // TODO: This error class doesn't seem to exist anymore, not sure what happens to multiholes now + // | NoType(MultiError) => + // /* NOTE: possible cause explanation actually helps. + // e.g. when generating + // "if i == index then (description, not(done)) else (description, done)" + // it would tend not to parethesize the argument to not + // */ + // prn( + // "Incomplete syntax (possible cause: remember that function application is c-style and requires parentheses around the argument)", + // ) + | NoType(BadToken(token)) => prn("\"%s\" isn't a valid token", token) + | NoType(BadTrivAp(ty)) => + prn( + "Function argument type \"%s\" inconsistent with ()", + Print.typ(ty), + ) + | Inconsistent(WithArrow(ty)) => + prn("type %s is not consistent with arrow type", Print.typ(ty)) + | NoType(FreeConstructor(_name)) => prn("Constructor is not defined") + | Inconsistent(Internal(tys)) => + prn( + "Expecting branches to have consistent types but got types: %s", + List.map(Print.typ, tys) |> String.concat(", "), + ) + | Inconsistent(Expectation({ana, syn})) => + prn( + "Expecting type %s but got inconsistent type %s", + Print.typ(ana), + Print.typ(syn), + ); + + let exp_error: Info.error_exp => string = + fun + | FreeVariable(name) => "Variable " ++ name ++ " is not bound" + | InexhaustiveMatch(_) => "Match is not exhaustive" //TODO: elaborate + | UnusedDeferral => "Unused deferral" //TODO: better message + | BadPartialAp(_) => "Bad partial application" //TODO: elaborate + | Common(error) => common_error(error); + + let pat_error: Info.error_pat => string = + fun + | ExpectedConstructor => "Expected a constructor" + | Redundant(_) => "Redundant" //TODO: elaborate + | Common(error) => common_error(error); + + let typ_error: Info.error_typ => string = + fun + | FreeTypeVariable(name) => prn("Type variable %s is not bound", name) + | BadToken(token) => prn("\"%s\" isn't a valid type token", token) + | WantConstructorFoundAp => "Expected a constructor, found application" + | WantConstructorFoundType(ty) => + prn("Expected a constructor, found type %s", Print.typ(ty)) + | WantTypeFoundAp => "Constructor application must be in sum" + | DuplicateConstructor(name) => + prn("Constructor %s already used in this sum", name); + + let tpat_error: Info.error_tpat => string = + fun + | NotAVar(_) => "Not a valid type name" //TODO: elaborate + | ShadowsType(name, _source) => "Can't shadow type " ++ name; //TODO: elaborate + + let string_of: Info.error => string = + fun + | Exp(error) => exp_error(error) + | Pat(error) => pat_error(error) + | Typ(error) => typ_error(error) + | TPat(error) => tpat_error(error); + + let format_error = (term, error) => + prn("Error in term:\n %s\nNature of error: %s", term, error); + + let term_string_of: Info.t => string = + fun + | InfoExp({term, _}) => Print.term(Exp(term)) + | InfoPat({term, _}) => Print.term(Pat(term)) + | InfoTyp({term, _}) => Print.term(Typ(term)) + | InfoTPat({term, _}) => Print.term(TPat(term)) + | Secondary(_) => failwith("ChatLSP: term_string_of: Secondary"); + + let collect_static = (info_map: Statics.Map.t): list(string) => { + Id.Map.fold( + (_id, info: Info.t, acc) => + switch (Info.error_of(info)) { + | None => acc + | Some(_) => [info] @ acc + }, + info_map, + [], + ) + |> List.sort_uniq(compare) + |> List.filter_map(info => + switch (Info.error_of(info)) { + | None => None + | Some(error) => + let term = term_string_of(info); + Some(format_error(term, string_of(error))); + } + ); + }; + + let get_top_level_errs = (init_ctx, mode, top_ci: Info.exp) => { + let self: Self.t = + switch (top_ci) { + | {self, _} => + switch (Self.typ_of_exp(init_ctx, self)) { + | None => Just(Typ.fresh(Unknown(Internal))) + | Some(ty) => Just(ty) + } + }; + let status = Info.status_common(init_ctx, mode, self); + switch (status) { + | InHole(Inconsistent(Expectation({ana, syn}))) => [ + "The suggested completion has the wrong expected type: expected " + ++ Print.typ(ana) + ++ ", but got " + ++ Print.typ(syn) + ++ ".", + ] + | _ => [] + }; + }; + + let get_parse_errs = (completion: string): Result.t(Zipper.t, string) => + switch (Printer.zipper_of_string(completion)) { + | None => Error("Undocumented parse error, no feedback available") + | Some(completion_z) => + //TODO: For syntax errors, also collect bad syntax eg % operator + switch ( + completion_z.backpack + |> List.map((s: Selection.t) => + Printer.of_segment(~holes=None, s.content) + ) + ) { + | [_, ..._] as orphans => + Error( + "The parser has detected the following unmatched delimiters:. The presence of a '=>' in the list likely indicates that a '->' was mistakingly used in a case expression: " + ++ String.concat(", ", orphans), + ) + | [] => Ok(completion_z) + } + }; + + let mk_errors = (~init_ctx, ~mode, reply: string): t => + switch (get_parse_errs(reply)) { + | Error(err) => ParseError(err) + | Ok(completion_z) => + //TODO: This is implictly specialized for expressions only + let (top_ci, info_map) = statics_of_exp_zipper(init_ctx, completion_z); + let static_errs = + get_top_level_errs(init_ctx, mode, top_ci) @ collect_static(info_map); + if (List.length(static_errs) == 0) { + NoErrors; + } else { + StaticErrors(static_errs); + }; + }; + + let mk = (~init_ctx: Ctx.t, ~mode: Mode.t, reply: string): option(string) => { + let wrap = (intro, errs) => + [intro] + @ errs + @ [ + "Please try to address the error(s) by updating your previous code suggestion", + "Please respond ONLY with the update suggestion", + ] + |> String.concat("\n"); + let error_report = mk_errors(~init_ctx, ~mode, reply); + switch (error_report) { + | NoErrors => None + | ParseError(err) => + Some(wrap("The following parse error occured:", [err])) + | StaticErrors(errs) => + Some(wrap("The following static errors were discovered:", errs)) + }; + }; +}; + +module RelevantType = { + let expected_ty = (~ctx, mode: Mode.t): Typ.t => { + switch (mode) { + | Ana({term: Var(name), _}) when Ctx.lookup_alias(ctx, name) != None => + let ty_expanded = Ctx.lookup_alias(ctx, name) |> Option.get; + ty_expanded; + | _ => Mode.ty_of(mode) + }; + }; + + let format_def = (alias: string, ty: Typ.t): string => + prn("type %s = %s in", alias, Print.typ(ty)); + + let subst_if_rec = ((name: string, ty: Typ.t)): (string, Typ.t) => { + switch (ty) { + | {term: Rec(name', ty'), _} => ( + name, + Typ.subst(Typ.fresh(Var(name)), name', ty'), + ) + | _ => (name, ty) + }; + }; + + let rec get_vars = (ty: Typ.t): list(string) => + switch (ty.term) { + | Int + | Float + | Bool + | String + | Unknown(_) => [] + | Var(x) => [x] + | Arrow(ty1, ty2) => get_vars(ty1) @ get_vars(ty2) + | Prod(tys) => ListUtil.flat_map(get_vars, tys) + | Sum(sm) => + List.concat_map( + fun + | ConstructorMap.BadEntry(_) => [] + | Variant(_, _, None) => [] + | Variant(_, _, Some(typ)) => get_vars(typ), + sm, + ) + | Rec({term: Var(x), _}, ty) => + /* Remove recursive type references */ + get_vars(ty) |> List.filter((x': string) => x' != x) + | Rec(_, ty) => get_vars(ty) + | List(ty) => get_vars(ty) + | Parens(ty) => get_vars(ty) + | Forall({term: Var(x), _}, ty) => + get_vars(ty) |> List.filter((x': string) => x' != x) + | Forall(_, ty) => get_vars(ty) + | Ap(ty1, ty2) => get_vars(ty1) @ get_vars(ty2) + }; + + let rec collect_aliases_deep = + (ctx: Ctx.t, ty: Typ.t): list((string, Typ.t)) => { + let ty_vars = get_vars(ty); + let defs = + ListUtil.flat_map( + var => + switch (Ctx.lookup_alias(ctx, var)) { + | Some(ty) => [(var, ty)] + | None => [(var, Typ.fresh(Unknown(Internal)))] + }, + ty_vars, + ) + |> List.sort_uniq(((x, _), (y, _)) => compare(x, y)); + let rec_calls = + ListUtil.flat_map( + ((_, ty')) => collect_aliases_deep(ctx, ty'), + defs, + ); + rec_calls @ defs; + }; + + let collate_aliases = (ctx: Ctx.t, expected_ty': Typ.t): option(string) => { + let defs = + collect_aliases_deep(ctx, expected_ty') + |> Util.ListUtil.dedup + |> List.map(subst_if_rec) + |> List.map(((alias, ty)) => format_def(alias, ty)); + switch (defs) { + | [] => None + | _ => Some(defs |> String.concat("\n")) + }; + }; + + let expected = (~ctx: Ctx.t, mode: Mode.t): string => { + /* TODO: Maybe include more than just the immediate type. + * like for example, when inside a fn(s), include argument types. + * Like basically to benefit maximally from included type info, + * want to make sure we're including the full expansion of any type + * we might want to either case on or construct. Rxpected type should + * mostly(?) give us the latter, but not always the former. */ + let prefix = "# The expected type of the hole ?? is: "; + switch (mode) { + | Ana(ty) => + let defs = + switch (collate_aliases(ctx, Mode.ty_of(mode))) { + | Some(defs) => + "# The following type definitions are likely relevant: #\n" ++ defs + | None => "\n" + }; + prefix ++ "a type consistent with " ++ Print.typ(ty) ++ " #\n" ++ defs; + | SynFun => + prefix + ++ "a type consistent with " + ++ Print.typ( + Typ.fresh( + Arrow( + Typ.fresh(Unknown(Internal)), + Typ.fresh(Unknown(Internal)), + ), + ), + ) + ++ " #" + | Syn => prefix ++ "any type #" + | _ => "Not applicable" + }; + }; +}; + +module RelevantCtx = { + [@deriving (show({with_path: false}), sexp, yojson)] + type filtered_entry = { + name: string, + typ: Typ.t, + matched_type: Typ.t, + depth: int, + }; + + /* TODO: For all functions on types, we want to makse sure we're + * normalizing first where appropriate (replacing type aliases with + * their definitions). Where it's always appropriate, internalize it + * into the relevant function; otherwise, list it as a precondition */ + + /* TODO: Some of the functions below were hastily updated to dev. + * The new cases of Typ (Parens, Forsll, Ap) especially should be + * double-checked */ + + let is_list_unk = (ty: Typ.t): bool => + switch (ty.term) { + | List({term: Unknown(_), _}) => true + | _ => false + }; + + let is_base = (ty: Typ.t): bool => + switch (ty.term) { + | Int + | Float + | Bool + | String => true + | _ => false + }; + + let returns_base = (ty: Typ.t): bool => + switch (ty.term) { + | Arrow(_, ty) => is_base(ty) + | _ => false + }; + + /* Calculates the total number of nodes (compound + and leaf) in the type tree. */ + let rec num_nodes = (ty: Typ.t): int => { + switch (ty.term) { + | Int + | Float + | Bool + | String + | Unknown(_) => 1 + | Var(_) => 1 + | Arrow(t1, t2) => 1 + num_nodes(t1) + num_nodes(t2) + | Prod(tys) => + 1 + List.fold_left((acc, ty) => acc + num_nodes(ty), 0, tys) + | Sum(sm) => + 1 + + List.fold_left( + (acc, variant) => + switch (variant) { + | ConstructorMap.BadEntry(_) => acc + | Variant(_, _, ty) => + acc + Util.OptUtil.get(() => 0, Option.map(num_nodes, ty)) + }, + 0, + sm, + ) + | Rec(_, ty) => 1 + num_nodes(ty) + | List(ty) => 1 + num_nodes(ty) + | Parens(ty) => 1 + num_nodes(ty) + | Forall(_, ty) => 1 + num_nodes(ty) + | Ap(ty1, ty2) => 1 + num_nodes(ty1) + num_nodes(ty2) + }; + }; + + let rec count_unknowns = (ty: Typ.t): int => + switch (ty.term) { + | Unknown(_) => 1 + | Int + | Float + | Bool + | String + | Var(_) => 0 + | Arrow(t1, t2) => count_unknowns(t1) + count_unknowns(t2) + | Prod(tys) => + List.fold_left((acc, ty) => acc + count_unknowns(ty), 0, tys) + | Sum(sm) => + List.fold_left( + (acc, variant) => + switch (variant) { + | ConstructorMap.BadEntry(_) => acc + | Variant(_, _, ty) => + acc + Util.OptUtil.get(() => 0, Option.map(count_unknowns, ty)) + }, + 0, + sm, + ) + | Rec(_, ty) => count_unknowns(ty) + | List(ty) => count_unknowns(ty) + | Parens(ty) => count_unknowns(ty) + | Forall(_, ty) => count_unknowns(ty) + | Ap(ty1, ty2) => count_unknowns(ty1) + count_unknowns(ty2) + }; + + let rec contains_sum_or_var = (ty: Typ.t): bool => + switch (ty.term) { + | Int + | Float + | Bool + | String + | Unknown(_) => false + | Var("Option") => false //TODO: hack for LSP + | Var(_) + | Sum(_) => true + | Arrow(t1, t2) => contains_sum_or_var(t1) || contains_sum_or_var(t2) + | Prod(tys) => List.exists(contains_sum_or_var, tys) + | Rec(_, ty) => contains_sum_or_var(ty) + | List(ty) => contains_sum_or_var(ty) + | Parens(ty) => contains_sum_or_var(ty) + | Forall(_, ty) => contains_sum_or_var(ty) + | Ap(ty1, ty2) => contains_sum_or_var(ty1) || contains_sum_or_var(ty2) + }; + + /* Returns the ratio of type nodes which are the Unknown + constructor. Must recurse and gather results from composite nodes */ + let unknown_ratio = (ty: Typ.t): float => { + let total = float_of_int(num_nodes(ty)); + let unknowns = float_of_int(count_unknowns(ty)); + (total -. unknowns) /. total; + }; + + let score_type = (ty: Typ.t): float => { + let unk_ratio = unknown_ratio(ty); + is_base(ty) ? 0.8 : unk_ratio; + }; + + let take_up_to_n = (n: int, xs: list('a)): list('a) => + switch (Util.ListUtil.split_n_opt(n, xs)) { + | Some((xs, _)) => xs + | None => xs + }; + + let format_def = (name: string, ty: Typ.t) => + prn("let %s: %s = in", name, Print.typ(ty)); + + let filter_ctx = (ctx: Ctx.t, ty_expect: Typ.t): list(filtered_entry) => + List.filter_map( + fun + | Ctx.VarEntry({typ, name, _}) + when Typ.is_consistent(ctx, ty_expect, typ) => + Some({name, typ, depth: 0, matched_type: typ}) + | Ctx.VarEntry({typ: {term: Arrow(_, return_ty), _} as typ, name, _}) + when Typ.is_consistent(ctx, ty_expect, return_ty) => + Some({name, typ, matched_type: return_ty, depth: 1}) + | Ctx.VarEntry({ + typ: {term: Arrow(_, {term: Arrow(_, return_ty), _}), _} as typ, + name, + _, + }) + when Typ.is_consistent(ctx, ty_expect, return_ty) => + Some({name, typ, matched_type: return_ty, depth: 2}) + | _ => None, + ctx, + ); + + let str = (ctx: Ctx.t, mode: Mode.t): string => { + let primary_goal: Typ.t = + RelevantType.expected_ty(~ctx, mode) |> Typ.normalize(ctx); + let secondary_targets = + switch (primary_goal.term) { + | Arrow(_source, target) => + let terts = + switch (target.term) { + | Prod(ts) => ts + | _ => [] + }; + [target] @ terts; + | _ => [] + }; + let primary_entries = filter_ctx(ctx, primary_goal); + let secondary_entries = + List.concat(List.map(filter_ctx(ctx, _), secondary_targets)); + let combined_entries = + secondary_entries + @ primary_entries + |> Util.ListUtil.dedup + |> List.sort((t1, t2) => + compare(score_type(t2.matched_type), score_type(t1.matched_type)) + ) + |> List.filter(entry => contains_sum_or_var(entry.typ)); + let entries = + combined_entries + |> take_up_to_n(8) + |> List.map(({name, typ, _}) => format_def(name, typ)) + |> String.concat("\n"); + "# Consider using these variables relevant to the expected type: #\n" + ++ entries; + }; +}; + +module Samples = { + type t = list((string, string, string)); + + //TODO: I think some of these examples are syntactically incorrect + let samples: t = [ + ( + {| +let List.length: [(String, Bool)]-> Int = + fun xs -> + ?? end in +|}, + RelevantType.expected(Ana(Typ.fresh(Int)), ~ctx=[]), + {| +DISCUSSION +The function List.length takes a list of (String, Bool) tuples and returns an Int. The natural way to compute the length of a list is through recursion. +The base case for an empty list is 0, and for a non-empty list, we increment the count and recursively call List.length on the tail. +``` +case xs +| [] => 0 +| _::xs => 1 + List.length(xs) +```|}, + ), + ( + {| +let List.mapi: ((Int, Bool) -> Bool, [Bool]) -> [Bool]= + fun f, xs -> + let go: (Int, [Bool])-> [Bool] = fun idx, xs -> + ?? end in + go(0, xs) in +|}, + RelevantType.expected( + Ana(Typ.fresh(List(Typ.fresh(Bool)))), + ~ctx=[], + ), + {| +DISCUSSION +The function List.mapi applies a function f to each element of a list while keeping track of the index. The helper function go does this recursively. +The base case returns an empty list. In the recursive case, f(idx, hd) is applied to the head, and go(idx + 1, tl) is called recursively on the tail to process the rest of the list. +``` +case xs +| [] => [] +| hd::tl => f(idx, hd)::go(idx + 1, tl) +```|}, + ), + ( + {| +type Container = + + Pod(Bool) + + CapsuleCluster(Int, Int) in +let total_capacity: Container -> Int = + ?? +in +|}, + RelevantType.expected( + Ana( + Typ.fresh(Arrow(Typ.fresh(Var("Container")), Typ.fresh(Int))), + ), + ~ctx=[], + ), + {| +DISCUSSION +The function total_capacity takes a Container and returns an Int. The Pod variant stores a Bool, which likely indicates whether the pod is active. +The condition if !b && true simplifies to if !b, meaning inactive pods have a capacity of 1, while active ones have 0. +The CapsuleCluster variant contains two integers, which are multiplied together to represent the total capacity. +``` +fun c -> + case c + | Pod(b) => if !b && true then 1 else 0 + | CapsuleCluster(x, y) => x * y + end +``` +|}, + ), + ( + "let f = ?? in f(5)", + RelevantType.expected(Syn, ~ctx=[]), + {| +DISCUSSION +The expression let f = ?? in f(5) means f should be a function that can take an integer input. A function of type fun x:Int -> ?? is defined, but its body is missing. +Since no constraints are placed on the output type, the hole could be filled with any valid expression. +``` +fun x:Int -> ?? +``` + |}, + ), + ( + {|let triple = (4, 8, true) in +let (_, y, condition) = triple in +let get: Option -> Int = +fun maybe_num -> + case maybe_num + | Some(x) => ?? + | None => if !condition then 0 else y + 1 end in|}, + RelevantType.expected(Ana(Typ.fresh(Int)), ~ctx=[]), + {| +DISCUSSION +The function get extracts a value from an Option type. If Some(x), the function should return x, as x is already of type Int. +The None case considers a condition; if !condition is true, it returns 0, otherwise, it returns y + 1. +Since x is an Int, returning it in the Some case maintains type consistency. +``` +x +``` + |}, + ), + ( + "let num_or_zero = fun maybe_num ->\n case maybe_num\n | Some(num) => ?? \n| None => 0 end in", + RelevantType.expected(Syn, ~ctx=[]), + {| +DISCUSSION +The function num_or_zero takes an Option(Int) and returns an Int. If the input is Some(num), it should return num, as num is already an integer. +If None, the function defaults to returning 0. This ensures type consistency while preserving the stored number when available. +``` +num +``` + |}, + ), + ( + "let merge_sort: [Int]->[Int] =\n??\nin\nmerge_sort([4,1,3,7,2])", + RelevantType.expected( + Ana( + Typ.fresh( + Arrow( + Typ.fresh(List(Typ.fresh(Int))), + Typ.fresh(List(Typ.fresh(Int))), + ), + ), + ), + ~ctx=[], + ), + {| +DISCUSSION +The function merge_sort sorts a list of integers. A common approach to implementing merge sort involves: +1. Splitting the list into two halves (split). +2. Recursively sorting both halves (merge_sort_helper). +3. Merging the sorted halves (merge). +The provided structure follows this approach, so we use helper functions to complete the sorting logic. +``` +fun list ->\nlet split: [Int]->([Int],[Int]) = fun left, right -> ?\nin\nlet merge: ([Int],[Int])->[Int]= ?\nin\nlet merge_sort_helper: [Int]->[Int]= ?\nin\nmerge_sort_helper(list) +``` + |}, + ), + ( + "type MenuItem =\n+ Breakfast(Int, Int)\n+ Lunch(Float)\nin\nlet per_lunch_unit = 0.95 in\nlet price: MenuItem-> Float = fun m ->\ncase m\n| Breakfast(x, y) => ??\n| Lunch(f) => f *. per_lunch_unit\nend\nin price(Breakfast(1,2))/.3.", + RelevantType.expected(Ana(Typ.fresh(Var("MenuItem"))), ~ctx=[]), + {| +DISCUSSION +The function price computes the cost of a MenuItem. The Lunch variant already has a predefined price calculation. For Breakfast(x, y), an expression must return a Float, but the completion is missing. +The function should ensure a proper numeric computation based on x and y. +``` +fun m ->\ncase m\n| Breakfast(x, y) => ??\n| Lunch(f) => f *. per_lunch_unit\nend +``` + |}, + ), + ( + {| +let List.merge: (( , )->Bool,[ ], [ ]) -> [ ] = fun cmp,left, right -> +case left, right +| [], _ => right +| _, [] => left +| h1::t1, h2::t2 => +if cmp(h1, h2) +then h1 :: List.merge(cmp, t1, right) +else h2 :: List.merge(cmp,left, t2) +end +in + +let List.sort: ((?, ?) -> Bool, [?]) -> [?] = +fun cmp, list -> +let merge_sort_helper: [?] -> [?] = fun l -> +case l +| [] => ? +| [x] => [x] +| _ => ?? +end +in merge_sort_helper(list) +in +test 2 == List.nth(List.sort(fun a, b -> a + switch (Util.ListUtil.split_n_opt(num_examples, samples)) { + | Some(samples) => + samples |> fst |> List.map(((s, t, u)) => (s, Some(t), u)) + | None => [] + }; +}; + +module SystemPrompt = { + let main_prompt = [ + "CODE COMPLETION INSTRUCTIONS:", + "- First, provide a brief discussion of your approach and reasoning", + "- Then, provide your code completion for the hole marked '??' enclosed in triple backticks", + "- Your response MUST include two parts:", + " 1. A discussion section explaining your approach", + " 2. Your code completion inside triple backticks", + "- DO NOT include anything else in your response", + "- DO NOT provide multiple code suggestions", + "- DO NOT include any text after the code block", + "- Here is an example of the format you should follow:", + "- DISCUSSION", + "- The function takes an integer n as input and returns a float.", + "- The base case returns 1.0 when n is 0, ensuring the function adheres to the expected Float return type.", + "- For all other cases, the function returns 2.0, maintaining consistency in return type while providing a simple branching structure.", + " ```", + " fun n -> if n == 0 then 1.0 else 2.0", + " ```", + "- The code completion should be a functional, idiomatic replacement for the program hole marked '??' in the provided program sketch", + // Uncomment either of the following lines to test error rounds + // "- However, make sure that your initial response CAUSES A TYPE ERROR in the program. Then, fix it in your second response", + // "- However, it is CRITICALLY important to make sure that your response ALWAYS CAUSES A TYPE ERROR in the program, no matter how many times you are re-prompted", + "- Reply ONLY with a SINGLE replacement term for the unique distinguished hole marked '??'", + "- DO NOT suggest more replacements for other holes in the sketch (marked '?'), or implicit holes", + "- This is critical, and I am going to reiterate it: DO NOT suggest more than one replacement term. It should ONLY be for the hole marked '??'", + "- For example, if you are being asked to complete 'let f = ? in ??', your response should ONLY be a single replacement term for the hole marked '??', NOT a replacement term for the hole marked '?'", + "- i.e. You should ONLY respond with a function application, or something else which would be a valid replacement term for the hole marked '??'", + "- IT WOULD BE A HUGE MISTAKE TO RESPOND WITH A FUNCTION BODY FOR THE HOLE MARKED '?'", + "- DO NOT include the program sketch in your reply", + "- DO NOT include a period at the end of your response and DO NOT use markdown", + ]; + + let hazel_syntax_notes = [ + "HAZEL SYNTAX NOTES:", + "- Hazel uses C-style function application syntax, with parenthesis around comma-separated arguments", + "- Function application is ALWAYS written using parentheses and commas: use 'function(arg1, arg2)'. DO NOT just use spaces between the function name and arguments.", + "- Function parameters are ALWAYS commas separated: 'fun arg1, arg2 -> '. DO NOT use spaces to separate function arguments.", + "- There is no dot accessor notation for tuples; DO NOT use tuple.field. use pattern matching for destructuring: let (field, _) = tuple in ...", + "- The following ARE NOT Hazel keywords. DO NOT use these keywords: switch, with, of, rec. ALWAYS omit these keywords", + "- Pattern matching is ALWAYS written a 'case ... end' expression. Cases MUST END in an 'end' keyword. DO NOT USE any other keyword besides 'case' to do pattern matching. DO NOT USE a 'with' or 'of' keyword with 'case', just start the list of rules. Pattern matching rules use syntax '| pattern => expression'. Note the '=>' arrow.", + "- The ONLY way to define a named function is by using a function expression nested in a let expression like 'let = fun -> in + String.concat( + "\n", + (instructions ? main_prompt : []) + @ (syntax_notes ? hazel_syntax_notes : []), + ); +}; + +module Prompt = { + //TODO: Build JSON instead of string + let mk_user_message = + ( + ~expected_ty: option(string), + ~relevant_ctx: option(string), + sketch: string, + ) + : string => + "{\n" + ++ String.concat( + ",\n", + List.filter_map( + Fun.id, + [ + Some("sketch: " ++ sketch), + Option.map(Printf.sprintf("expected_ty: %s"), expected_ty), + Option.map(Printf.sprintf("relevant_ctx:\n %s"), relevant_ctx), + ], + ), + ) + ++ ",\n}"; + + let static_context = + ( + {expected_type, relevant_ctx, _}: Options.t, + ci: Info.t, + sketch: Segment.t, + ) + : option(string) => { + //TODO: Proper errors + let* mode = + switch (ci) { + | InfoExp({mode, _}) => Some(mode) + | InfoPat({mode, _}) => Some(mode) + | _ => None + }; + let sketch = Print.seg(~holes=Some("?"), sketch); + let+ () = String.trim(sketch) == "" ? None : Some(); + let ctx_at_caret = Info.ctx_of(ci); + let expected_ty = + expected_type + ? Some(RelevantType.expected(~ctx=ctx_at_caret, mode)) : None; + let relevant_ctx = + relevant_ctx ? Some(RelevantCtx.str(ctx_at_caret, mode)) : None; + mk_user_message(sketch, ~expected_ty, ~relevant_ctx); + }; + + let samples = (num_examples: int): list(OpenRouter.message) => + Util.ListUtil.flat_map( + ((sketch, expected_ty, completion)): list(OpenRouter.message) => + [ + { + role: User, + content: + mk_user_message(sketch, ~expected_ty, ~relevant_ctx=None), + }, + {role: Assistant, content: completion}, + ], + Samples.get(num_examples), + ); + + let mk_init = + (options: Options.t, ci: Info.t, sketch: Segment.t) + : option(OpenRouter.prompt) => { + let+ user_message = static_context(options, ci, sketch); + OpenRouter.[{role: System, content: SystemPrompt.mk(options)}] + @ samples(options.num_examples) + @ [{role: User, content: user_message}]; + }; + + let mk_error = (ci: Info.t, reply: string): option(string) => { + /* TODO: This should maybe take whole JSON convo + * so far and return an appended version */ + //TODO: Proper errors + let* mode = + switch (ci) { + | InfoExp({mode, _}) => Some(mode) + | InfoPat({mode, _}) => Some(mode) + | _ => None + }; + let init_ctx = Info.ctx_of(ci); + ErrorPrint.mk(~init_ctx, ~mode, reply); + }; +}; diff --git a/src/haz3lweb/app/helpful-assistant/Oracle.re b/src/haz3lweb/app/helpful-assistant/Oracle.re new file mode 100644 index 0000000000..9e04ee8f66 --- /dev/null +++ b/src/haz3lweb/app/helpful-assistant/Oracle.re @@ -0,0 +1,33 @@ +open Haz3lcore; + +let sanitize_prompt = (prompt: string): string => { + //HACK: replacement of ?? below + let prompt = Str.global_replace(Str.regexp("\\?\\?"), "", prompt); + let prompt = + if (Str.string_match(Str.regexp("^\".*\"$"), prompt, 0)) { + String.sub(prompt, 1, String.length(prompt) - 2); + } else { + prompt; + }; + prompt; +}; + +let ask = (body: string): option(OpenRouter.prompt) => { + /* + let system_prompt = [ + "Respond as minimally as possible", + "Do not include a period at the end of your response", + ]; + */ + switch (String.trim(body)) { + | "" => None + | _ => + let input = [{OpenRouter.role: User, OpenRouter.content: body}]; + Some(input); + }; +}; + +let sanitize_response: string => string = + Str.global_replace(Str.regexp("\""), "'"); + +let quote = s => "\"" ++ s ++ "\""; diff --git a/src/haz3lweb/app/input/Shortcut.re b/src/haz3lweb/app/input/Shortcut.re index 5f664d7361..43972c87ef 100644 --- a/src/haz3lweb/app/input/Shortcut.re +++ b/src/haz3lweb/app/input/Shortcut.re @@ -176,8 +176,8 @@ let shortcuts = (sys: Util.Key.sys): list(t) => mk_shortcut( ~section="Settings", ~mdIcon="tune", - "Toggle Show Docs Sidebar", - Globals(Set(ExplainThis(ToggleShow))), + "Toggle Show Sidebar", + Globals(Set(Sidebar(ToggleShow))), ), mk_shortcut( ~section="Settings", diff --git a/src/haz3lweb/app/inspector/CursorInspector.re b/src/haz3lweb/app/inspector/CursorInspector.re index b33aec0586..8c0cdc8e63 100644 --- a/src/haz3lweb/app/inspector/CursorInspector.re +++ b/src/haz3lweb/app/inspector/CursorInspector.re @@ -14,25 +14,47 @@ let code_box_container = x => let code_err = (code: string): Node.t => div(~attrs=[clss(["code"])], [text(code)]); -let explain_this_toggle = (~globals: Globals.t): Node.t => { - let tooltip = "Toggle language documentation"; - let toggle_explain_this = _ => - Virtual_dom.Vdom.Effect.Many([ - globals.inject_global(Set(ExplainThis(ToggleShow))), - Virtual_dom.Vdom.Effect.Stop_propagation, - ]); - div( - ~attrs=[clss(["explain-this-button"])], - [ - Widgets.toggle( - ~tooltip, - "?", - globals.settings.explainThis.show, - toggle_explain_this, - ), - ], - ); -}; +/* + let explain_this_toggle = (~globals: Globals.t): Node.t => { + let tooltip = "Switch Language ocumentation"; + let toggle_explain_this = _ => + Virtual_dom.Vdom.Effect.Many([ + globals.inject_global(Set(ExplainThis(ToggleShow))), + Virtual_dom.Vdom.Effect.Stop_propagation, + ]); + div( + ~attrs=[clss(["explain-this-button"])], + [ + Widgets.toggle( + ~tooltip, + "?", + globals.settings.explainThis.show, + toggle_explain_this, + ), + ], + ); + }; + + let assistant_toggle = (~globals: Globals.t): Node.t => { + let tooltip = "Switch LLM-assistant coder"; + let toggle_assistant = _ => + Virtual_dom.Vdom.Effect.Many([ + globals.inject_global(Set(Assistant(ToggleShow))), + Virtual_dom.Vdom.Effect.Stop_propagation, + ]); + div( + ~attrs=[clss(["assisstant-button"])], + [ + Widgets.toggle( + ~tooltip, + "?", + globals.settings.assistant.show, + toggle_assistant, + ), + ], + ); + }; + */ let cls_view = (ci: Info.t): Node.t => div( @@ -59,8 +81,9 @@ let term_view = (~globals: Globals.t, ci) => { ], [ ctx_toggle(~globals), + // russ todo: this bottom-bar needs to be cleaned up now with + // toggle replaced in favor of persisten sidebar div(~attrs=[clss(["term-tag"])], [text(sort)]), - explain_this_toggle(~globals), cls_view(ci), ], ); diff --git a/src/haz3lweb/app/sidebar/Sidebar.re b/src/haz3lweb/app/sidebar/Sidebar.re new file mode 100644 index 0000000000..4bfb67de64 --- /dev/null +++ b/src/haz3lweb/app/sidebar/Sidebar.re @@ -0,0 +1,88 @@ +open Virtual_dom.Vdom; +open Node; +open Util.Web; +open Util; +open Haz3lcore; + +let tab = (~tooltip="", icon, action, isActive) => { + let classes = ["tab"] @ (isActive ? ["active"] : []); + div( + ~attrs=[clss(classes), Attr.on_mousedown(action), Attr.title(tooltip)], + [icon], + ); +}; + +let explain_this_tab = (~globals: Globals.t): Node.t => { + let tooltip = "Switch to Language Documentation"; + let switch_explain_this = _ => + Virtual_dom.Vdom.Effect.Many([ + globals.inject_global( + Set(Sidebar(SwitchWindow(LanguageDocumentation))), + ), + Virtual_dom.Vdom.Effect.Stop_propagation, + ]); + div( + ~attrs=[clss(["explain-this-button"])], + [ + tab( + Icons.explain_this, + ~tooltip, + switch_explain_this, + globals.settings.sidebar.window == LanguageDocumentation + && globals.settings.sidebar.show, + ), + ], + ); +}; + +let assistant_tab = (~globals: Globals.t): Node.t => { + let tooltip = "Switch to Helpful Assistant"; + let switch_assistant = _ => + Virtual_dom.Vdom.Effect.Many([ + globals.inject_global(Set(Sidebar(SwitchWindow(HelpfulAssistant)))), + Virtual_dom.Vdom.Effect.Stop_propagation, + ]); + div( + ~attrs=[clss(["assistant-button"])], + [ + tab( + Icons.assistant, + ~tooltip, + switch_assistant, + globals.settings.sidebar.window == HelpfulAssistant + && globals.settings.sidebar.show, + ), + ], + ); +}; + +let collapse_tab = (~globals: Globals.t): Node.t => { + let tooltip = + globals.settings.sidebar.show ? "Collapse Sidebar" : "Expand Sidebar"; + let icon = globals.settings.sidebar.show ? Icons.collapse : Icons.expand; + let switch_assistant = _ => + Virtual_dom.Vdom.Effect.Many([ + globals.inject_global(Set(Sidebar(ToggleShow))), + Virtual_dom.Vdom.Effect.Stop_propagation, + ]); + div( + ~attrs=[clss(["collapse-button"])], + [tab(icon, ~tooltip, switch_assistant, false)], + ); +}; + +let persistent_view = (~globals: Globals.t, ~inject: 'a => Effect.t(unit)) => { + div( + ~attrs=[Attr.id("persistent")], + [ + div( + ~attrs=[clss(["tabs"])], + [ + explain_this_tab(~globals), + assistant_tab(~globals), + collapse_tab(~globals), + ], + ), + ], + ); +}; diff --git a/src/haz3lweb/app/sidebar/SidebarModel.re b/src/haz3lweb/app/sidebar/SidebarModel.re new file mode 100644 index 0000000000..aa3b9b2b70 --- /dev/null +++ b/src/haz3lweb/app/sidebar/SidebarModel.re @@ -0,0 +1,21 @@ +module Sexp = Sexplib.Sexp; +open Haz3lcore; +open Util; + +module Settings = { + [@deriving (show({with_path: false}), sexp, yojson)] + type window = + | LanguageDocumentation + | HelpfulAssistant; + + [@deriving (show({with_path: false}), sexp, yojson)] + type t = { + show: bool, + window, + }; + + [@deriving (show({with_path: false}), sexp, yojson)] + type action = + | ToggleShow + | SwitchWindow(window); +}; diff --git a/src/haz3lweb/debug/DebugConsole.re b/src/haz3lweb/debug/DebugConsole.re index 47e0883cc8..7c24936fbc 100644 --- a/src/haz3lweb/debug/DebugConsole.re +++ b/src/haz3lweb/debug/DebugConsole.re @@ -33,6 +33,48 @@ let print = }; | None => print("DEBUG: No indicated index") }; + | "F9" => + print_endline("STATIC CONTEXT AT CURSOR:"); + Util.OptUtil.Syntax.( + switch ( + { + let* index = Indicated.index(zipper); + let* ci = Id.Map.find_opt(index, map); + let sketch_seg = + Zipper.smart_seg(~dump_backpack=true, ~erase_buffer=true, zipper); + ChatLSP.Prompt.mk_init(ChatLSP.Options.init, ci, sketch_seg); + } + ) { + | None => print_endline("prompt generation failed") + | Some(prompt) => + List.iter( + (message: OpenRouter.message) => { + print_endline("---------- STRING ----------"); + print_endline(message.content); + print_endline("---------- STRING ----------"); + }, + prompt, + ) + } + ); + | "F10" => + print_endline("WHOLE PROGRAM ERROR REPORT:"); + switch ( + { + let whole_program_str = + zipper + |> Zipper.smart_seg(~dump_backpack=true, ~erase_buffer=true) + |> ChatLSP.Print.seg; + ChatLSP.ErrorPrint.mk( + ~init_ctx=Builtins.ctx_init, + ~mode=Syn, + whole_program_str, + ); + } + ) { + | None => print_endline("error reply generation failed") + | Some(prompt) => print_endline(prompt) + }; | _ => print("DEBUG: No action for key: " ++ key) }; }; diff --git a/src/haz3lweb/util/API/API.re b/src/haz3lweb/util/API/API.re new file mode 100644 index 0000000000..c5fa0530ef --- /dev/null +++ b/src/haz3lweb/util/API/API.re @@ -0,0 +1,161 @@ +open Js_of_ocaml; +open Util.OptUtil.Syntax; + +let opt = Util.OptUtil.and_then; + +type request = Js.t(XmlHttpRequest.xmlHttpRequest); + +type method = + | GET + | POST + | PUT + | DELETE; + +let string_of_method = + fun + | GET => "GET" + | POST => "POST" + | PUT => "PUT" + | DELETE => "DELETE"; + +module Json = { + type t = Yojson.Safe.t; + let to_string = Yojson.Safe.to_string; + let from_string = Yojson.Safe.from_string; + let bool = (json: t): option(bool) => + switch (json) { + | `Bool(b) => Some(b) + | _ => None + }; + let int = (json: t): option(int) => + switch (json) { + | `Int(n) => Some(n) + | _ => None + }; + let float = (json: t): option(float) => + switch (json) { + | `Float(f) => Some(f) + | _ => None + }; + let str = (json: t): option(string) => + switch (json) { + | `String(str) => Some(str) + | _ => None + }; + let list = (json: t): option(list(t)) => + switch (json) { + | `List(xs) => Some(xs) + | _ => None + }; + let get_kvs = (json: t): option(list((string, t))) => + switch (json) { + | `Assoc(pairs) => Some(pairs) + | _ => None + }; + let dot = (key: string, json: t): option(t) => { + let* pairs = get_kvs(json); + List.assoc_opt(key, pairs); + }; +}; + +let receive = (~debug=true, request: request): option(Json.t) => + switch (request##.readyState) { + | XmlHttpRequest.DONE => + debug ? Firebug.console##log(request##.responseText) : (); + Js.Opt.case( + request##.responseText, + () => None, + x => Some(x |> Js.to_string |> Json.from_string), + ); + | _ => None + }; + +let request = + ( + ~debug=false, + ~with_credentials=false, + ~method: method, + ~url: string, + ~headers: list((string, string))=[], + ~body: Json.t=`Null, + handler: option(Json.t) => unit, + ) + : unit => { + debug ? Yojson.Safe.pp(Format.std_formatter, body) : (); + let request = XmlHttpRequest.create(); + request##.onreadystatechange := + Js.wrap_callback(_ => handler(receive(request))); + request##.withCredentials := with_credentials |> Js.bool; + request##_open( + method |> string_of_method |> Js.string, + url |> Js.string, + true |> Js.bool, + ); + for (i in 0 to List.length(headers) - 1) { + let (key, value) = List.nth(headers, i); + request##setRequestHeader(Js.string(key), Js.string(value)); + }; + request##send(body |> Json.to_string |> Js.string |> Js.Opt.return); +}; + +let node_request = + ( + ~debug=false, + ~with_credentials=false, + ~method: method, + ~hostname: string, /* Do not include 'https://' */ + ~path: string, + ~headers: list((string, string))=[], + ~body: Json.t=`Null, + handler: option(Json.t) => unit, + ) + : unit => { + let https = Js.Unsafe.js_expr("require('https')"); + debug ? Yojson.Safe.pp(Format.std_formatter, body) : (); + let options = + Printf.sprintf( + "({hostname: \"%s\", path: \"%s\", method: \"%s\", headers: { %s } })", + hostname, + path, + string_of_method(method), + headers + |> List.map(((k, v)) => Printf.sprintf("\"%s\": \"%s\"", k, v)) + |> String.concat(","), + ); + debug ? Printf.printf("options: %s", options) : (); + let callback = + Js.wrap_callback(res => { + let data = ref(""); + res##on( + Js.string("data"), + Js.wrap_callback(chunk => + data := data^ ++ Js.to_string(chunk##toString) + ), + ); + res##on( + Js.string("end"), + Js.wrap_callback(_ => + handler( + try(Some(Json.from_string(data.contents))) { + | _ => None + }, + ) + ), + ); + }); + let req = https##request(Js.Unsafe.js_expr(options), callback); + if (with_credentials) { + req##withCredentials := Js._true; + }; + ignore( + req##on( + Js.string("error"), + Js.wrap_callback(error => { + Firebug.console##log("Error occurred:"); + Firebug.console##log(error); + }), + ), + ); + ignore(req##write(Js.string(Json.to_string(body)))); + ignore(req##end_()); +}; diff --git a/src/haz3lweb/util/API/OpenAI.re b/src/haz3lweb/util/API/OpenAI.re new file mode 100644 index 0000000000..20317699c7 --- /dev/null +++ b/src/haz3lweb/util/API/OpenAI.re @@ -0,0 +1,207 @@ +module Sexp = Sexplib.Sexp; +open API; +open Util.OptUtil.Syntax; +open Util; + +[@deriving (show({with_path: false}), sexp, yojson)] +type chat_models = + | GPT4 + | GPT3_5Turbo + | Azure_GPT4_0613 + | Azure_GPT3_5Turbo; + +[@deriving (show({with_path: false}), sexp, yojson)] +type role = + | System + | User + | Assistant + | Function; + +[@deriving (show({with_path: false}), sexp, yojson)] +type params = { + llm: chat_models, + temperature: float, + top_p: float, +}; + +[@deriving (show({with_path: false}), sexp, yojson)] +type message = { + role, + content: string, +}; + +[@deriving (show({with_path: false}), sexp, yojson)] +type prompt = list(message); + +[@deriving (show({with_path: false}), sexp, yojson)] +type usage = { + prompt_tokens: int, + completion_tokens: int, + total_tokens: int, +}; + +[@deriving (show({with_path: false}), sexp, yojson)] +type reply = { + content: string, + usage, +}; + +[@deriving (show({with_path: false}), sexp, yojson)] +let string_of_chat_model = + fun + | GPT4 => "gpt-4" + | GPT3_5Turbo => "gpt-3.5-turbo" + | Azure_GPT4_0613 => "azure-gpt-4" + | Azure_GPT3_5Turbo => "azure-gpt-3.5-turbo"; + +let string_of_role = + fun + | System => "system" + | User => "user" + | Assistant => "assistant" + | Function => "function"; + +let default_params = {llm: Azure_GPT4_0613, temperature: 1.0, top_p: 1.0}; + +let mk_message = ({role, content}) => + `Assoc([ + ("role", `String(string_of_role(role))), + ("content", `String(content)), + ]); + +let body = (~params: params, messages: prompt): Json.t => { + `Assoc([ + ("model", `String(string_of_chat_model(params.llm))), + ("temperature", `Float(params.temperature)), + ("top_p", `Float(params.top_p)), + ("messages", `List(List.map(mk_message, messages))), + ]); +}; + +let lookup_key = (llm: chat_models) => + switch (llm) { + | Azure_GPT3_5Turbo => Store.Generic.load("AZURE") + | Azure_GPT4_0613 => Store.Generic.load("AZURE4") + | GPT3_5Turbo + | GPT4 => Store.Generic.load("OpenAI") + }; + +/* SAMPLE OPENAI CHAT RESPONSE: + { + "id":"chatcmpl-6y5167eYM6ovo5yVThXzr5CB8oVIO", + "object":"chat.completion", + "created":1679776984, + "model":"gpt-3.5-turbo-0301", + "usage":{ + "prompt_tokens":25, + "completion_tokens":1, + "total_tokens":26 + }, + "choices":[ + { + "message":{ + "role":"assistant", + "content":"576" + }, + "finish_reason":"stop", + "index":0 + } + ] + }*/ + +let chat = (~key, ~body, ~handler): unit => + switch (key) { + | None => print_endline("API: OpenAI KEY NOT FOUND") + | Some(api_key) => + print_endline("API: POSTing OpenAI request"); + request( + ~method=POST, + ~url="https://api.openai.com/v1/chat/completions", + ~headers=[ + ("Content-Type", "application/json"), + ("Authorization", "Bearer " ++ api_key), + ], + ~body, + handler, + ); + }; + +let azure_request = + (~key, ~resource, ~deployment, ~api_version, ~body, ~handler): unit => + switch (key) { + | None => print_endline("API: KEY NOT FOUND") + | Some(api_key) => + print_endline("API: POSTing Azure request"); + request( + ~method=POST, + ~url= + Printf.sprintf( + "https://%s.openai.azure.com/openai/deployments/%s/chat/completions?api-version=%s", + resource, + deployment, + api_version, + ), + ~headers=[("Content-Type", "application/json"), ("api-key", api_key)], + ~body, + handler, + ); + }; + +let chat_azure35 = + azure_request( + ~resource="hazel", + ~deployment="gpt35turbo", + ~api_version="2023-05-15", + ); + +let chat_azure4 = + azure_request( + ~resource="hazel2", + ~deployment="hazel-gpt-4", + ~api_version="2023-05-15", + ); + +let start_chat = (~params, ~key, prompt: prompt, handler): unit => { + let body = body(~params, prompt); + switch (params.llm) { + | Azure_GPT3_5Turbo => chat_azure35(~key, ~body, ~handler) + | Azure_GPT4_0613 => chat_azure4(~key, ~body, ~handler) + | GPT3_5Turbo + | GPT4 => chat(~key, ~body, ~handler) + }; +}; + +let int_field = (json: Json.t, field: string) => { + let* num = Json.dot(field, json); + Json.int(num); +}; + +let of_usage = (choices: Json.t): option(usage) => { + let* prompt_tokens = int_field(choices, "prompt_tokens"); + let* completion_tokens = int_field(choices, "completion_tokens"); + let+ total_tokens = int_field(choices, "total_tokens"); + {prompt_tokens, completion_tokens, total_tokens}; +}; + +let first_message_content = (choices: Json.t): option(string) => { + let* choices = Json.list(choices); + let* hd = Util.ListUtil.hd_opt(choices); + let* message = Json.dot("message", hd); + let* content = Json.dot("content", message); + Json.str(content); +}; + +let handle_chat = (~db=ignore, response: option(Json.t)): option(reply) => { + db("OpenAI: Chat response:"); + Option.map(r => r |> Json.to_string |> db, response) |> ignore; + let* json = response; + let* choices = Json.dot("choices", json); + let* usage = Json.dot("usage", json); + let* content = first_message_content(choices); + let+ usage = of_usage(usage); + {content, usage}; +}; + +let add_to_prompt = (prompt, ~assistant, ~user): prompt => + prompt + @ [{role: Assistant, content: assistant}, {role: User, content: user}]; diff --git a/src/haz3lweb/util/API/OpenRouter.re b/src/haz3lweb/util/API/OpenRouter.re new file mode 100644 index 0000000000..d06cfb8515 --- /dev/null +++ b/src/haz3lweb/util/API/OpenRouter.re @@ -0,0 +1,150 @@ +module Sexp = Sexplib.Sexp; +open API; +open Util.OptUtil.Syntax; +open Util; + +[@deriving (show({with_path: false}), sexp, yojson)] +type chat_models = + | Gemini_Flash_Lite_2_0 + | Gemini_Experimental_1206 + | Llama_3_1_Nemo + | DeepSeek_V3; + +[@deriving (show({with_path: false}), sexp, yojson)] +type role = + | System + | User + | Assistant + | Function; + +[@deriving (show({with_path: false}), sexp, yojson)] +type params = { + llm: chat_models, + temperature: float, + top_p: float, +}; + +[@deriving (show({with_path: false}), sexp, yojson)] +type message = { + role, + content: string, +}; + +[@deriving (show({with_path: false}), sexp, yojson)] +type prompt = list(message); + +[@deriving (show({with_path: false}), sexp, yojson)] +type usage = { + prompt_tokens: int, + completion_tokens: int, + total_tokens: int, +}; + +[@deriving (show({with_path: false}), sexp, yojson)] +type reply = { + content: string, + usage, +}; + +[@deriving (show({with_path: false}), sexp, yojson)] +let string_of_chat_model = + fun + | Gemini_Flash_Lite_2_0 => "google/gemini-2.0-flash-lite-preview-02-05:free" + | Gemini_Experimental_1206 => "google/gemini-exp-1206:free" + | Llama_3_1_Nemo => "nvidia/llama-3.1-nemotron-70b-instruct:free" + | DeepSeek_V3 => "deepseek/deepseek-chat:free"; + +let string_of_role = + fun + | System => "system" + | User => "user" + | Assistant => "assistant" + | Function => "function"; + +let default_params = { + llm: Gemini_Flash_Lite_2_0, + temperature: 1.0, + top_p: 1.0, +}; + +let mk_message = ({role, content}) => + `Assoc([ + ("role", `String(string_of_role(role))), + ("content", `String(content)), + ]); + +let body = (~params: params, messages: prompt): Json.t => { + `Assoc([ + ("model", `String(string_of_chat_model(params.llm))), + ("temperature", `Float(params.temperature)), + ("top_p", `Float(params.top_p)), + ("messages", `List(List.map(mk_message, messages))), + ]); +}; + +let lookup_key = (llm: chat_models) => + switch (llm) { + | Gemini_Flash_Lite_2_0 => Store.Generic.load("API") + | Gemini_Experimental_1206 => Store.Generic.load("API") + | Llama_3_1_Nemo => Store.Generic.load("API") + | DeepSeek_V3 => Store.Generic.load("API") + }; + +let chat = (~key, ~body, ~handler): unit => { + print_endline("API: POSTing OpenRouter request"); + request( + ~method=POST, + ~url="https://openrouter.ai/api/v1/chat/completions", + ~headers=[ + ("Content-Type", "application/json"), + ("Authorization", "Bearer " ++ key), + ], + ~body, + handler, + ); +}; + +let start_chat = (~params, ~key, prompt: prompt, handler): unit => { + let body = body(~params, prompt); + switch (params.llm) { + | Gemini_Flash_Lite_2_0 => chat(~key, ~body, ~handler) + | Gemini_Experimental_1206 => chat(~key, ~body, ~handler) + | Llama_3_1_Nemo => chat(~key, ~body, ~handler) + | DeepSeek_V3 => chat(~key, ~body, ~handler) + }; +}; + +let int_field = (json: Json.t, field: string) => { + let* num = Json.dot(field, json); + Json.int(num); +}; + +let of_usage = (choices: Json.t): option(usage) => { + let* prompt_tokens = int_field(choices, "prompt_tokens"); + let* completion_tokens = int_field(choices, "completion_tokens"); + let+ total_tokens = int_field(choices, "total_tokens"); + {prompt_tokens, completion_tokens, total_tokens}; +}; + +let first_message_content = (choices: Json.t): option(string) => { + let* choices = Json.list(choices); + let* hd = Util.ListUtil.hd_opt(choices); + let* message = Json.dot("message", hd); + let* content = Json.dot("content", message); + Json.str(content); +}; + +let handle_chat = (~db=ignore, response: option(Json.t)): option(reply) => { + db("OpenAI: Chat response:"); + Option.map(r => r |> Json.to_string |> db, response) |> ignore; + let* json = response; + let* choices = Json.dot("choices", json); + let* usage = Json.dot("usage", json); + let* content = first_message_content(choices); + let+ usage = of_usage(usage); + {content, usage}; +}; + +let add_to_prompt = (prompt, ~assistant, ~user): prompt => + prompt + @ [{role: Assistant, content: assistant}, {role: User, content: user}]; diff --git a/src/haz3lweb/view/ExerciseMode.re b/src/haz3lweb/view/ExerciseMode.re index d8bc55fafa..5b49b1b678 100644 --- a/src/haz3lweb/view/ExerciseMode.re +++ b/src/haz3lweb/view/ExerciseMode.re @@ -82,14 +82,14 @@ module Update = { }; | Editor(pos, MainEditor(action)) => switch (CodeSelectable.Update.convert_action(action)) { - | Some(action) => + | Some(a) => let editor = Exercise.main_editor_of_state(~selection=pos, model.editors); let* new_editor = // Hack[Matt]: put Editor.t into a CodeSelectable.t to use its update function editor |> CodeSelectable.Model.mk - |> CodeSelectable.Update.update(~settings, action); + |> CodeSelectable.Update.update(~settings, a); { ...model, editors: diff --git a/src/haz3lweb/view/NutMenu.re b/src/haz3lweb/view/NutMenu.re index 2155d0f26f..ddb0c92071 100644 --- a/src/haz3lweb/view/NutMenu.re +++ b/src/haz3lweb/view/NutMenu.re @@ -46,12 +46,7 @@ let semantics_group = (~globals) => { ("τ", "Types", globals.settings.core.statics, Statics), ("⇲", "Completion", globals.settings.core.assist, Assist), ("𝛿", "Evaluation", globals.settings.core.dynamics, Dynamics), - ( - "?", - "Docs", - globals.settings.explainThis.show, - ExplainThis(ToggleShow), - ), + ("?", "Docs", globals.settings.sidebar.show, Sidebar(ToggleShow)) // Russ todo: Idk what this does, need to check back // ( // "👍", // "Feedback", diff --git a/src/haz3lweb/view/Page.re b/src/haz3lweb/view/Page.re index a55487c0fe..fa228285cd 100644 --- a/src/haz3lweb/view/Page.re +++ b/src/haz3lweb/view/Page.re @@ -16,6 +16,7 @@ module Model = { globals: Globals.Model.t, editors: Editors.Model.t, explain_this: ExplainThisModel.t, + assistant: Assistant.Model.t, selection, }; @@ -31,10 +32,12 @@ module Store = { ~instructor_mode=globals.settings.instructor_mode, ); let explain_this = ExplainThisModel.Store.load(); + let assistant = Assistant.Store.load(); { editors, globals, explain_this, + assistant, selection: Editors.Selection.default_selection(editors), }; }; @@ -46,6 +49,7 @@ module Store = { ); Globals.Model.save(m.globals); ExplainThisModel.Store.save(m.explain_this); + Assistant.Store.save(m.assistant); }; }; @@ -62,6 +66,7 @@ module Update = { | Globals(Globals.Update.t) | Editors(Editors.Update.t) | ExplainThis(ExplainThisUpdate.update) + | Assistant(Assistant.Update.t) | MakeActive(selection) | Benchmark(benchmark_action) | Start @@ -75,6 +80,15 @@ module Update = { action: Globals.Update.t, model: Model.t, ) => { + /* A function passed down to trigger an update within + assistant which checks for the insertion of '??' */ + let send_insertion_info = (~char, ~editor) => { + Assistant.Update.check_req( + char, + a => schedule_action(Assistant(a)), + editor, + ); + }; switch (action) { | SetMousedown(mousedown) => { @@ -127,6 +141,7 @@ module Update = { Editors.Update.update( ~globals, ~schedule_action=a => schedule_action(Editors(a)), + ~send_insertion_info, action, model.editors, ); @@ -158,6 +173,7 @@ module Update = { Editors.Update.update( ~globals=model.globals, ~schedule_action=a => schedule_action(Editors(a)), + ~send_insertion_info, action, model.editors, ); @@ -176,6 +192,7 @@ module Update = { Editors.Update.update( ~globals=model.globals, ~schedule_action=a => schedule_action(Editors(a)), + ~send_insertion_info, action, model.editors, ); @@ -194,6 +211,7 @@ module Update = { Editors.Update.update( ~globals=model.globals, ~schedule_action=a => schedule_action(Editors(a)), + ~send_insertion_info, action, model.editors, ); @@ -210,6 +228,15 @@ module Update = { action: t, model: Model.t, ) => { + /* A function passed down to trigger an update within + assistant which checks for the insertion of '??' */ + let send_insertion_info = (~char, ~editor) => { + Assistant.Update.check_req( + char, + a => schedule_action(Assistant(a)), + editor, + ); + }; let globals = { ...model.globals, export_all: Export.export_all, @@ -223,6 +250,7 @@ module Update = { Editors.Update.update( ~globals, ~schedule_action=a => schedule_action(Editors(a)), + ~send_insertion_info, action, model.editors, ); @@ -231,6 +259,60 @@ module Update = { let* explain_this = ExplainThisUpdate.set_update(model.explain_this, action); {...model, explain_this}; + | Assistant(action) => + let settings = globals.settings; + let ed: CellEditor.Model.t = + switch (model.editors) { + | Scratch(m) => List.nth(m.scratchpads, m.current) |> snd + | Documentation(m) => List.nth(m.scratchpads, m.current) |> snd + | Exercises(m) => List.nth(m.exercises, m.current).cells.user_impl // Todo this is an error + }; + open Haz3lcore; + let add_suggestion = (~response: string, tile: Id.t, resuggest: bool) => { + /* + let tile_content = + TileMap.find_opt(tile, ed.editor.editor.syntax.tiles) + |> Option.map((tile: Tile.t) => tile.label) + |> Option.map(List.hd); + switch (tile_content) { + | Some(content) => print_endline("tile_content: " ++ content) + | None => print_endline("tile_content: None") + }; + */ + print_endline("resuggest: " ++ string_of_bool(resuggest)); + let actions = + resuggest + ? [ + Action.Select(Tile(Id(tile, Direction.Left))), + Action.Buffer(Set(LLMSug(response))), + ] + : [ + Action.Select(Tile(Id(tile, Direction.Left))), + Action.Destruct(Direction.Left), + Action.Buffer(Set(LLMSug(response))), + ]; + // Apply each action in sequence + List.iter( + action => { + let perform_action = CodeEditable.Update.Perform(action); + let cell_action = CellEditor.Update.MainEditor(perform_action); + let scratch_action = + Editors.Update.Scratch(CellAction(cell_action)); + schedule_action(Editors(scratch_action)); + }, + actions, + ); + }; + let* assistant = + Assistant.Update.update( + ~settings, + ~action, + ~editor=ed.editor, + ~model=model.assistant, + ~schedule_action=a => schedule_action(Assistant(a)), + ~add_suggestion, + ); + {...model, assistant}; | MakeActive(selection) => {...model, selection} |> Updated.return | Benchmark(Start) => List.iter(a => schedule_action(Editors(a)), Benchmark.actions_1); @@ -334,44 +416,77 @@ module View = { JsUtil.focus_clipboard_shim(); Effect.Ignore; }), - Attr.on_copy(_ => { - JsUtil.copy( - (cursor.selected_text |> Option.value(~default=() => ""))(), - ); + Attr.on_copy(evt => { + let target = Js.Opt.to_option(evt##.target); + switch (target) { + | Some(el) => + let elId = Js.Opt.to_option(Js.Unsafe.coerce(el)##.id); + switch (elId) { + | Some("message-input") => () + | Some("api-input") => () + | _ => + JsUtil.copy( + (cursor.selected_text |> Option.value(~default=() => ""))(), + ) + }; + | None => () + }; Effect.Ignore; }), - Attr.on_cut(_ => { - JsUtil.copy( - (cursor.selected_text |> Option.value(~default=() => ""))(), - ); - Option.map( - inject, - Selection.handle_key_event( - ~selection=Some(model.selection), - ~event= - Key.{ - key: D("Delete"), - sys: Os.is_mac^ ? Mac : PC, - shift: Up, - meta: Up, - ctrl: Up, - alt: Up, - }, - model, - ), - ) - |> Option.value(~default=Effect.Ignore); + Attr.on_cut(evt => { + let target = Js.Opt.to_option(evt##.target); + switch (target) { + | Some(el) => + let elId = Js.Opt.to_option(Js.Unsafe.coerce(el)##.id); + switch (elId) { + | Some("message-input") => Effect.Ignore + | Some("api-input") => Effect.Ignore + | _ => + JsUtil.copy( + (cursor.selected_text |> Option.value(~default=() => ""))(), + ); + Option.map( + inject, + Selection.handle_key_event( + ~selection=Some(model.selection), + ~event= + Key.{ + key: D("Delete"), + sys: Os.is_mac^ ? Mac : PC, + shift: Up, + meta: Up, + ctrl: Up, + alt: Up, + }, + model, + ), + ) + |> Option.value(~default=Effect.Ignore); + }; + | None => Effect.Ignore + }; }), ] @ [ Attr.on_paste(evt => { - let pasted_text = - Js.to_string(evt##.clipboardData##getData(Js.string("text"))) - |> Str.global_replace(Str.regexp("\n[ ]*"), "\n"); - Dom.preventDefault(evt); - switch (cursor.editor_action(Paste(pasted_text))) { + let target = Js.Opt.to_option(evt##.target); + switch (target) { + | Some(el) => + let elId = Js.Opt.to_option(Js.Unsafe.coerce(el)##.id); + switch (elId) { + | Some("message-input") => Effect.Ignore + | Some("api-input") => Effect.Ignore + | _ => + let pasted_text = + Js.to_string(evt##.clipboardData##getData(Js.string("text"))) + |> Str.global_replace(Str.regexp("\n[ ]*"), "\n"); + Dom.preventDefault(evt); + switch (cursor.editor_action(Paste(pasted_text))) { + | None => Effect.Ignore + | Some(action) => inject(Editors(action)) + }; + }; | None => Effect.Ignore - | Some(action) => inject(Editors(action)) }; }), ]; @@ -452,7 +567,13 @@ module View = { ~get_log_and: (string => unit) => unit, ~inject: Update.t => Ui_effect.t(unit), ~cursor: Cursor.cursor(Editors.Update.t), - {globals, editors, explain_this: explainThisModel, selection} as model: Model.t, + { + globals, + editors, + explain_this: explainThisModel, + assistant: assistantModel, + selection, + } as model: Model.t, ) => { let globals = { ...globals, @@ -466,15 +587,40 @@ module View = { ~inject=a => inject(Editors(a)), cursor, ); - let sidebar = - globals.settings.explainThis.show && globals.settings.core.statics - ? ExplainThis.view( - ~globals, - ~inject=a => inject(ExplainThis(a)), - ~explainThisModel, - cursor.info, - ) - : div([]); + let sidebar = { + let sub = + globals.settings.sidebar.show + ? switch (globals.settings.sidebar.window) { + | LanguageDocumentation => + ExplainThis.view( + ~globals, + ~inject=action => inject(ExplainThis(action)), + ~explainThisModel, + cursor.info, + ) + | HelpfulAssistant => + open Editors.View; + let signal = ( + fun + | MakeActive(selection) => inject(MakeActive(selection)) + ); + AssistantView.view( + ~globals, + ~signal= + fun + | MakeActive(s) => signal(MakeActive(Scratch(s))), + ~inject=action => inject(Assistant(action)), + ~assistantModel, + ); + } + : { + div([]); + }; + div( + ~attrs=[Attr.id("sidebars")], + [sub, Sidebar.persistent_view(~globals, ~inject)], + ); + }; let editors_view = Editors.View.view( ~globals, diff --git a/src/haz3lweb/view/ScratchMode.re b/src/haz3lweb/view/ScratchMode.re index 8e922aae72..3e76c7742f 100644 --- a/src/haz3lweb/view/ScratchMode.re +++ b/src/haz3lweb/view/ScratchMode.re @@ -86,6 +86,7 @@ module Update = { let update = ( ~schedule_action, + ~send_insertion_info, ~settings: Settings.t, ~is_documentation: bool, action, @@ -95,6 +96,18 @@ module Update = { | CellAction(a) => let (key, ed) = List.nth(model.scratchpads, model.current); let* new_ed = CellEditor.Update.update(~settings, a, ed); + switch (a) { + | MainEditor(action) => + switch (action) { + | Perform(a) => + switch (a) { + | Insert(char) => send_insertion_info(~char, ~editor=new_ed.editor) + | _ => () + } + | _ => () + } + | _ => () + }; let new_sp = ListUtil.put_nth(model.current, (key, new_ed), model.scratchpads); {...model, scratchpads: new_sp}; @@ -193,30 +206,40 @@ module Selection = { open Cursor; [@deriving (show({with_path: false}), sexp, yojson)] - type t = CellEditor.Selection.t; + type t = + | Cell(CellEditor.Selection.t) + | TextBox; let get_cursor_info = (~selection, model: Model.t): cursor(Update.t) => { - let+ ci = - CellEditor.Selection.get_cursor_info( - ~selection, - List.nth(model.scratchpads, model.current) |> snd, - ); - Update.CellAction(ci); + switch (selection) { + | Cell(s) => + let+ a = + CellEditor.Selection.get_cursor_info( + ~selection=s, + List.nth(model.scratchpads, model.current) |> snd, + ); + Update.CellAction(a); + | TextBox => empty + }; }; let handle_key_event = (~selection, ~event: Key.t, model: Model.t): option(Update.t) => - switch (event) { - | {key: D(key), sys: Mac | PC, shift: Up, meta: Down, ctrl: Up, alt: Up} - when Keyboard.is_digit(key) => - Some(Update.SwitchSlide(int_of_string(key))) - | _ => - CellEditor.Selection.handle_key_event( - ~selection, - ~event, - List.nth(model.scratchpads, model.current) |> snd, - ) - |> Option.map(x => Update.CellAction(x)) + switch (selection) { + | Cell(s) => + switch (event) { + | {key: D(key), sys: Mac | PC, shift: Up, meta: Down, ctrl: Up, alt: Up} + when Keyboard.is_digit(key) => + Some(Update.SwitchSlide(int_of_string(key))) + | _ => + CellEditor.Selection.handle_key_event( + ~selection=s, + ~event, + List.nth(model.scratchpads, model.current) |> snd, + ) + |> Option.map(x => Update.CellAction(x)) + } + | TextBox => None }; let jump_to_tile = (tile, model: Model.t): option((Update.t, t)) => @@ -224,19 +247,19 @@ module Selection = { tile, List.nth(model.scratchpads, model.current) |> snd, ) - |> Option.map(((x, y)) => (Update.CellAction(x), y)); + |> Option.map(((x, y)) => (Update.CellAction(x), Cell(y))); }; module View = { type event = - | MakeActive(CellEditor.Selection.t); + | MakeActive(Selection.t); let view = ( ~globals, ~signal: event => 'a, ~inject: Update.t => 'a, - ~selected: option(Selection.t), + ~selection: option(Selection.t), model: Model.t, ) => { ( @@ -250,9 +273,13 @@ module View = { ~globals, ~signal= fun - | MakeActive(selection) => signal(MakeActive(selection)), + | MakeActive(selection) => signal(MakeActive(Cell(selection))), ~inject=a => inject(CellAction(a)), - ~selected, + ~selected= + switch (selection) { + | Some(Selection.Cell(s)) => Some(s) + | _ => None + }, ~locked=false, List.nth(model.scratchpads, model.current) |> snd, ), diff --git a/src/haz3lweb/www/style.css b/src/haz3lweb/www/style.css index d70e70a422..4d20f5cfc8 100644 --- a/src/haz3lweb/www/style.css +++ b/src/haz3lweb/www/style.css @@ -9,6 +9,8 @@ @import "style/type-display.css"; @import "style/projectors-panel.css"; @import "style/explainthis.css"; +@import "style/assistant.css"; +@import "style/sidebar.css"; @import "style/exercise-mode.css"; @import "style/cell.css"; @import "style/dynamics.css"; @@ -96,14 +98,19 @@ select:hover { } #side-bar { - grid-row: 2 / span 1; - grid-column: 2 / span 1; + display: flex; + flex-direction: column; border-left: 0.6px solid var(--BR2); overflow-y: scroll; overflow-x: hidden; width: max-content; - background-color: var(--ui-bkg); - scrollbar-color: var(--main-scroll-thumb) var(--NONE); + background-color: var(--T2); +} + +#sidebars { + display: flex; + justify-content: space-between; + width: 100%; } #top-bar { diff --git a/src/haz3lweb/www/style/assistant.css b/src/haz3lweb/www/style/assistant.css new file mode 100644 index 0000000000..89ff3300bd --- /dev/null +++ b/src/haz3lweb/www/style/assistant.css @@ -0,0 +1,654 @@ +#assistant { + display: flex; + flex: 1; + width: 25em; + padding: 8px; + flex-direction: column; + overflow-y: auto; + gap: 1em; + overflow-x: hidden; /* Prevent outer scroll */ +} + +#assistant .assistant-button { + text-transform: none; +} + +#assistant .title { + text-transform: none; + font-size: 1em; + font-weight: bold; + padding-top: .5em; + padding-bottom: .5em; + color: var(--ui-header-text); +} + +#assistant .header { + display: flex; + text-transform: none; + font-weight: bold; + margin-bottom: 1em; + width: 100%; +} + +#assistant .header-content { + display: flex; + width: 100%; + justify-content: space-between; + align-items: center; +} + +#assistant .settings-box { + padding: 1em; + display: flex; + flex-direction: column; + align-items: center; + gap: 2.25em; + margin-top: auto; +} + +#assistant .chat-button { + background-color: var(--SAND); + color: var(--STONE); + font-size: .75em; + padding: 0.5em 1em; + border-radius: 4px; + font-weight: bold; + display: flex; + justify-content: center; + align-items: center; + width: auto; + height: 1.25em; + cursor: pointer; + transition: all 0.3s ease; + margin: 4px; +} + +#assistant .chat-button:hover { + background-color: var(--T1); + box-shadow: 0px 4px 6px var(--SHADOW); +} + +#assistant .chat-button:active { + background-color: var(--T3); +} + +#assistant .input-container { + align-items: center; + margin-top: auto; + padding: .2em 0em; + width: 100%; + display: flex; + gap: 10px; + transition: all 0.3s ease; +} + +#assistant .message-input { + flex: 1; + background-color: var(--SAND); + padding: 14px; + outline: none; + border-radius: 3.5px; + border: none; + transition: all 0.3s ease; +} + +#assistant .message-input:focus { + box-shadow: 0px 0px 1px var(--SHADOW), 0 0 0 1px var(--SHADOW); +} + +#assistant .send-button-disabled { + background-color: var(--SAND); + color: var(--STONE); + padding: .65em .75em; + border: 0 solid var(--SAND); + border-radius: .5em; + font-weight: bold; + cursor: not-allowed; + transition: all 0.3s ease; + pointer-events: none; + opacity: 0.5; +} + +#assistant .send-button-disabled:hover { + background-color: var(--SAND); + box-shadow: none; +} + +#assistant .send-button { + background-color: var(--SAND); + color: var(--STONE); + padding: .65em .75em; + border: 0 solid var(--SAND); + border-radius: .5em; + font-weight: bold; + cursor: pointer; + transition: all 0.3s ease; +} + +#assistant .send-button:hover { + background-color: var(--T1); + box-shadow: 0px 4px 6px var(--SHADOW); +} + +#assistant .send-button:active { + background-color: var(--T3); +} + +/* Message Display Container */ +#assistant .message-display-container { + display: flex; + flex-direction: column; + overflow-y: auto; + padding: 10px; + background-color: var(--UI-Background); + gap: 10px; + flex-grow: 1; +} + +/* Individual message container */ +#assistant .message-container { + display: flex; + flex-direction: column; + margin-bottom: 10px; + position: relative; +} + +#assistant .message-identifier { + font-size: 0.8em; + color: var(--STONE); + opacity: 0.7; + margin-bottom: 4px; + padding: 0 12px; +} + +#assistant .llm .message-identifier { + align-self: flex-start; +} + +#assistant .system .message-identifier { + align-self: flex-start; +} + +#assistant .ls .message-identifier { + align-self: flex-end; +} + +#assistant .llm { + align-items: flex-start; +} + +#assistant .system { + align-items: flex-start; +} + +#assistant .ls { + align-items: flex-end; +} + +#assistant .collapse-indicator { + width: 100%; + text-align: center; + color: var(--STONE); + font-size: 0.8em; + opacity: 0.7; + margin-top: 4px; +} + +#assistant .message-container.collapsed .collapse-indicator::after { + content: "▼ Show more"; +} + +#assistant .message-container.expanded .collapse-indicator::after { + content: "▲ Show less"; +} + +#assistant .llm-message { + padding: 8px 12px; + background-color: var(--T1); + border-radius: 12px; + font-size: 1em; + max-width: 80%; + word-wrap: break-word; +} + +#assistant .system-message { + padding: 8px 12px; + background-color: var(--ERRHOLE); + border-radius: 12px; + font-size: 1em; + max-width: 80%; + word-wrap: break-word; +} + +#assistant .ls-message { + padding: 8px 12px; + background-color: var(--T3); + border-radius: 12px; + font-size: 1em; + max-width: 80%; + word-wrap: break-word; +} + +#assistant .loading-dots { + display: flex; + align-items: center; + gap: 6px; + } + + #assistant .dot { + width: 8px; + height: 8px; + background-color: var(--STONE); + border-radius: 50%; + animation: bounce 1.4s infinite ease-in-out both; + } + + #assistant .dot1 { + animation-delay: -0.32s; + } + + #assistant .dot2 { + animation-delay: -0.16s; + } + + @keyframes bounce { + 0%, 80%, 100% { + transform: scale(0.7); + opacity: 0.5; + } + 40% { + transform: scale(1); + opacity: 1; + } + } + + /* API key container styling */ +#assistant .api-key-container { + display: flex; + flex-direction: column; + gap: 10px; + width: 100%; + align-items: center; + margin-top: 1em; +} + +#assistant .api-input { + width: 85%; + background-color: var(--SAND); + padding: 14px; + outline: none; + border-radius: 3.5px; + border: none; + transition: all 0.3s ease; + margin-bottom: 10px; +} + +#assistant .api-input:focus { + box-shadow: 0px 0px 1px var(--SHADOW), 0 0 0 1px var(--SHADOW); +} + +/* LLM and LSP toggle button containers */ +#assistant .llm-button, +#assistant .lsp-button { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + margin-bottom: 0.5em; + padding: 0 1em; +} + +/* Example message container */ +#assistant .message-container.example { + width: 100%; + max-width: 100%; + margin-bottom: 20px; +} + +#assistant .message-container.example .cell-editor { + width: 100%; + background-color: var(--UI-Background); + border-radius: 8px; + padding: 8px; +} + +#assistant .api-key-display { + width: 85%; + padding: 12px; + background-color: var(--UI-Background); + border-radius: 3.5px; + border: 1px solid var(--SAND); + font-size: 0.9em; + color: var(--STONE); + text-align: center; + word-wrap: break-word; + margin-bottom: 10px; + font-family: monospace; + overflow-x: auto; +} + +#assistant .api-key-label { + font-size: 0.85em; + color: var(--STONE); + opacity: 0.7; + margin-bottom: 5px; +} + +#assistant .api-key-container { + display: flex; + flex-direction: column; + gap: 10px; + width: 100%; + align-items: center; + margin-top: 1em; + margin-bottom: 1em; +} + +#assistant .text-display { + width: 85%; + padding: 12px; + background-color: var(--UI-Background); + font-size: 0.8em; + color: var(--STONE); + /* text-align: center; */ + word-wrap: break-word; + margin-bottom: -15px; + overflow-x: auto; +} + +.llm-selector { + margin: auto; +} + +.llm-label { + font-weight: bold; + margin-right: 10px; +} + +.llm-dropdown { + padding: 5px; + border-radius: 4px; + border: 1px solid #ccc; + background-color: #fff; + cursor: pointer; +} + +.llm-dropdown:hover { + border-color: #888; +} + +.llm-dropdown:focus { + outline: none; + border-color: #555; +} + +/* Mode buttons styling */ +#assistant .mode-buttons { + display: flex; + align-items: center; + gap: 4px; +} + +#assistant .mode-button { + padding: 0.35em 0.75em; + font-size: 0.75em; + color: var(--STONE); + cursor: pointer; + border-radius: 3px; + transition: all 0.2s ease; + text-align: center; +} + +#assistant .header-actions { + display: flex; + align-items: center; + gap: 4px; +} + +#assistant .mode-button:hover { + opacity: 0.7; +} + +#assistant .mode-button.active { + text-decoration: underline; + text-underline-offset: 8px; + color: var(--STONE); +} + +#assistant .add-button, +#assistant .history-button { + color: var(--STONE); + padding: .45em .5em; + border-radius: 4px; + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + transition: all 0.3s ease; + line-height: 0; +} + +#assistant .add-button:hover, +#assistant .history-button:hover { + background-color: var(--T1); + box-shadow: 0px 2px 4px var(--SHADOW); + opacity: 0.7; +} + +#assistant .add-button:active, +#assistant .history-button:active { + background-color: var(--T3); + opacity: 0.5; +} + +/* History Menu Styling */ +#assistant .history-menu { + position: absolute; + top: 3.5em; + right: 32.5em; + width: 350px; + /* background-color: var(--UI-Background); */ + /* border: 1px solid var(--SAND); */ + border-radius: 2px; + box-shadow: 0 4px 12px var(--SHADOW); + z-index: 1000; + overflow: hidden; + animation: slideIn 0.2s ease-out; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +#assistant .history-menu-header { + padding: 12px 16px; + background-color: var(--T1); + color: var(--STONE); + font-weight: bold; + border-bottom: 1px solid var(--SAND); + font-size: 0.9em; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +#assistant .history-menu-list { + background: var(--T2); + max-height: 400px; + overflow-y: auto; + padding: 4px 0; +} + +#assistant .history-menu-list::-webkit-scrollbar { + width: 8px; +} + +#assistant .history-menu-list::-webkit-scrollbar-track { + background: var(--UI-Background); +} + +#assistant .history-menu-list::-webkit-scrollbar-thumb { + background: var(--SAND); + border-radius: 4px; +} + +#assistant .history-menu-item { + padding: 10px 16px; + background-color: var(--T2); + color: var(--STONE); + cursor: pointer; + transition: all 0.1s ease; + border-bottom: 1px solid var(--SAND); + font-size: 0.9em; + display: flex; + align-items: center; +} + +#assistant .history-menu-item.active { + padding: 10px 16px; + background-color: var(--T3); + color: var(--STONE); + cursor: pointer; + transition: all 0.1s ease; + border-bottom: 1px solid var(--SAND); + font-size: 0.9em; + display: flex; + align-items: center; +} + +#assistant .history-menu-item:hover:not(:has(.button:hover)) { + background-color: var(--T4); + padding-left: 18px; +} + +#assistant .history-menu-item:active:not(:has(.button:hover)) { + background-color: var(--T3); +} + +#assistant .history-menu-item-content { + flex: 1; + margin-right: 8px; +} + +#assistant .history-menu-item-actions { + display: flex; + align-items: center; + gap: 8px; + transition: all 0.2s ease; +} + +/* Show delete button only on hover */ +#assistant .delete-chat-button { + color: var(--STONE); + padding: 4px; + border-radius: 4px; + cursor: pointer; + opacity: 0; + transition: all 0.2s ease; + margin-left: 8px; + margin-top: -10px; + line-height: 0; + display: none; + transform: scale(0.75); /* Shrink the icon to 75% of its original size */ +} + +#assistant .delete-chat-button svg { + width: 18px; /* Make the SVG icon smaller */ + height: 18px; +} + +#assistant .history-menu-item:hover .delete-chat-button { + opacity: 0.75; + display: block; +} + +#assistant .delete-chat-button:hover { + background-color: var(--T3); + opacity: 0.5 !important; +} + +#assistant .history-menu-item-time { + font-size: 0.725em; + color: var(--STONE); + margin-left: 8px; + margin-top: -12px; + transition: all 0.2s ease; +} + +#assistant .history-menu-item:last-child { + border-bottom: none; +} + +#assistant .resuggest-button { + color: var(--STONE); + padding: 4px 8px; + cursor: pointer; + opacity: 0.6; + transition: all 0.2s ease; + font-size: 0.6em; +} + +#assistant .resuggest-button:hover { + opacity: 0.9 !important; +} + +#assistant .example { + overflow-x: scroll !important; + overflow-y: scroll !important; + width: 100%; + min-height: fit-content; + max-height: 400px; + padding: 8px; + border-radius: 4px; + margin: 4px 0; + background: var(--SAND); + box-sizing: border-box; + display: block; + position: relative; /* Create new containing block */ +} + +#assistant .example .code { + display: inline-block; + white-space: nowrap; + min-width: min-content; + padding-bottom: 4px; + position: relative; /* For proper width calculation */ + width: max-content; /* Allow content to determine width */ +} + +#assistant .example .cell-editor { + white-space: pre; + display: inline-block; + position: relative; /* For proper width calculation */ +} + +/* Hide default editor scrollbar */ +#assistant .example .cell-editor::-webkit-scrollbar { + display: none; +} + +/* Style the example scrollbar */ +#assistant .example::-webkit-scrollbar { + height: 8px; +} + +#assistant .example::-webkit-scrollbar-thumb { + background: var(--STONE); + border-radius: 4px; +} + +#assistant .message-code { + overflow-x: auto; + white-space: pre; + max-width: 100%; + padding: 8px; + background: var(--SAND); + border-radius: 4px; + margin: 4px 0; +} \ No newline at end of file diff --git a/src/haz3lweb/www/style/editor.css b/src/haz3lweb/www/style/editor.css index 13ac029688..816cd49048 100644 --- a/src/haz3lweb/www/style/editor.css +++ b/src/haz3lweb/www/style/editor.css @@ -72,6 +72,7 @@ .code .token.explicit-hole { color: var(--token-explicit-hole); text-shadow: 1px 1px 0px var(--token-explicit-hole-shadow); + font-weight: bold; } .code .comment { @@ -89,6 +90,21 @@ font-size: 0.59em; } +.code .token.llm-waiting { + animation: rainbow 4s linear infinite; + font-weight: bold; +} + +@keyframes rainbow { + 0% { color: #4477ff; } /* Start with muted blue */ + 17% { color: #9944ff; } /* Muted purple */ + 33% { color: #ff4488; } /* Muted pink */ + 50% { color: #ff7744; } /* Muted orange */ + 67% { color: #88aa44; } /* Muted green */ + 83% { color: #44aaff; } /* Muted cyan */ + 100% { color: #4477ff; } /* Back to muted blue */ +} + /* TOKEN BACKING DECOS */ svg.shard > path { diff --git a/src/haz3lweb/www/style/explainthis.css b/src/haz3lweb/www/style/explainthis.css index 9399b5305b..5787e7923c 100644 --- a/src/haz3lweb/www/style/explainthis.css +++ b/src/haz3lweb/www/style/explainthis.css @@ -1,8 +1,10 @@ #explain-this { display: flex; - width: 20em; - padding: 0.4em; + flex: 1; + width: 25em; + padding: 1em; flex-direction: column; + overflow-y: auto; gap: 1em; } diff --git a/src/haz3lweb/www/style/palette.html b/src/haz3lweb/www/style/palette.html index 9a1ccf19c9..2f4d20fcd7 100644 --- a/src/haz3lweb/www/style/palette.html +++ b/src/haz3lweb/www/style/palette.html @@ -19,6 +19,7 @@ --T1: oklch(97% 0.025 90); /* buffer shards */ --T2: oklch(from var(--T1) 94% c h); /* projector shards */ --T3: oklch(from var(--T1) 91% c h); /* result background */ + --T4: oklch(from var(--T1) 88% c h); /* a darker background */ /* ROCK */ --BR1: oklch(85% 0.07 90); /* caret shard, token buffer*/ diff --git a/src/haz3lweb/www/style/sidebar.css b/src/haz3lweb/www/style/sidebar.css new file mode 100644 index 0000000000..a1a84a2c20 --- /dev/null +++ b/src/haz3lweb/www/style/sidebar.css @@ -0,0 +1,67 @@ +/* Persistent Sidebar +#sidebar { + display: flex; + flex: 1; + width: 20em; + padding: 1em; + flex-direction: column; + overflow-y: auto; + gap: 1em; + margin-right: 1em; +} */ + +#sidebar-button { + text-transform: none; +} + +/* Persistent Sidebar */ + +#persistent { + grid-row: 2 / span 1; + grid-column: 2 / span 1; + border-left: 0.6px solid var(--BR2); + overflow-y: scroll; + overflow-x: hidden; + display: flex; + flex: 1; + width: 5em; + flex-direction: column; + gap: 1em; + background-color: var(--ui-bkg); + scrollbar-color: var(--main-scroll-thumb) var(--NONE); +} + +#persistent .tabs { + display: flex; + flex-direction: column; + flex-grow: 1; +} + +#persistent .tab { + box-sizing: border-box; + display: flex; + justify-content: center; + align-items: center; + width: 5em; + height: 5em; + cursor: pointer; + transition: background-color 0.2s ease; +} + +.collapse-button { + margin-top: auto; +} + +#persistent .tab.active { + border-right: .2em solid var(--BR3); + padding-left: .2em; +} + +#persistent .tab:hover { + background-color: var(--T2); +} + +#persistent .tab:focus, +#persistent .tab:active { + background-color: var(--T2); +} diff --git a/src/haz3lweb/www/style/variables.css b/src/haz3lweb/www/style/variables.css index 143829787d..ab606238f3 100644 --- a/src/haz3lweb/www/style/variables.css +++ b/src/haz3lweb/www/style/variables.css @@ -24,6 +24,7 @@ --T1: oklch(97% 0.025 90); /* buffer shards */ --T2: oklch(from var(--T1) 94% c h); /* projector shards */ --T3: oklch(from var(--T1) 91% c h); /* result background */ + --T4: oklch(from var(--T1) 88% c h); /* darker background, used in assistant */ /* MOLTEN - under construction */ --Y0: oklch(95% 0.05 90); /* menu fill */ diff --git a/src/util/ListUtil.re b/src/util/ListUtil.re index 1e7e87a1af..5f5ebc3990 100644 --- a/src/util/ListUtil.re +++ b/src/util/ListUtil.re @@ -273,6 +273,9 @@ let rec fold_left_map = (final, [y, ...ys]); }; +let concat_strings = (strings: list(string)): string => + List.fold_left((acc, str) => acc ++ str, "", strings); + let rec take_while = (p: 'x => bool, xs: list('x)): (list('x), list('x)) => switch (xs) { | [] => ([], [])