From 69e864e899a933e4dd9dfa74f0048abdcf74ca98 Mon Sep 17 00:00:00 2001 From: tiye Date: Fri, 19 Dec 2025 19:27:35 +0800 Subject: [PATCH 1/8] experimental code --- moon.mod.json | 5 +- src/main/main.mbt | 24 ++++++++++ src/main/moon.pkg.json | 3 +- src/main/query_comp.mbt | 59 +++++++++++++++++++++++ src/main/requests.mbt | 104 ++++++++++++++++++++++++++++++++++++++++ src/main/store.mbt | 21 +++++++- 6 files changed, 212 insertions(+), 4 deletions(-) create mode 100644 src/main/query_comp.mbt create mode 100644 src/main/requests.mbt diff --git a/moon.mod.json b/moon.mod.json index 13f134b..7304008 100644 --- a/moon.mod.json +++ b/moon.mod.json @@ -4,7 +4,8 @@ "deps": { "tiye/respo": "0.2.3", "tiye/dom-ffi": "0.2.3", - "tiye/respo_css": "0.1.4" + "tiye/respo_css": "0.1.4", + "moonbitlang/async": "0.15.0" }, "readme": "README.md", "repository": "", @@ -13,4 +14,4 @@ "description": "", "source": "src", "preferred-target": "js" -} +} \ No newline at end of file diff --git a/src/main/main.mbt b/src/main/main.mbt index 1d0a0ad..d3e4469 100644 --- a/src/main/main.mbt +++ b/src/main/main.mbt @@ -17,6 +17,12 @@ fn view( // @dom_ffi.log("Store to render: " + store.to_json().stringify(indent=2)) div(class_name=@respo.ui_global, style=respo_style(padding=Px(12)), [ comp_counter(store.states.pick("counter"), store.counted), + comp_query( + store.states.pick("query"), + store.query_loading, + store.query_result, + store.query_error, + ), ]) } @@ -37,6 +43,24 @@ fn main { @dom_ffi.log("Action: \{op}") } app.store.val = app.store.val.update(op) + match op { + StartQuery(prompt) => { + let _ = @js_async.Promise::from_async(async fn() { + try { + let result = run_query(prompt) + app.store.val = app.store.val.update(QuerySuccess(result)) + @respo.mark_need_rerender() + } catch { + Failure(msg) => { + app.store.val = app.store.val.update(QueryError(msg)) + @respo.mark_need_rerender() + } + } + }) + () + } + _ => () + } }) let dev_mode = @dom_ffi.new_url_search_params(window.location().search()).get( "mode", diff --git a/src/main/moon.pkg.json b/src/main/moon.pkg.json index 59ac124..f8d9919 100644 --- a/src/main/moon.pkg.json +++ b/src/main/moon.pkg.json @@ -10,6 +10,7 @@ "alias": "respo_node" }, { "path": "tiye/dom-ffi", "alias": "dom_ffi" }, - { "path": "tiye/respo_css", "alias": "css" } + { "path": "tiye/respo_css", "alias": "css" }, + { "path": "moonbitlang/async/js_async", "alias": "js_async" } ] } diff --git a/src/main/query_comp.mbt b/src/main/query_comp.mbt new file mode 100644 index 0000000..3821ae9 --- /dev/null +++ b/src/main/query_comp.mbt @@ -0,0 +1,59 @@ +///| +struct QueryState { + input_val : String +} derive(Default, ToJson, @json.FromJson) + +///| +fn comp_query( + states : @respo.RespoStatesTree, + loading : Bool, + result : String, + error : String, +) -> @respo_node.RespoNode[ActionOp] { + let cursor = states.path() + let state = (states.cast_branch() : QueryState) + @respo_node.div( + class_name="query-comp", + style=@css.respo_style(padding=Px(12)), + [ + @respo_node.div([ + @respo_node.input( + value=state.input_val, + placeholder="Enter prompt...", + on_input=fn(e, dispatch) { + match e { + Input(value~, ..) => + dispatch.set_state(cursor, { input_val: value }) + _ => () + } + }, + style=@css.respo_style(width=Px(300), margin_right=Px(8)), + ), + @respo_node.button( + inner_text=if loading { "Loading..." } else { "Submit" }, + attrs={ "disabled": if loading { "true" } else { "false" } }, + on_click=fn(_e, dispatch) { + if not(loading) && not(state.input_val.is_empty()) { + dispatch.run(StartQuery(state.input_val)) + } + }, + ), + ]), + if not(error.is_empty()) { + @respo_node.div(style=@css.respo_style(color=Red, margin_top=Px(8)), [ + @respo_node.text_node(error), + ]) + } else { + @respo_node.span([]) + }, + if not(result.is_empty()) { + @respo_node.div( + style=@css.respo_style(margin_top=Px(8), white_space="pre-wrap"), + [@respo_node.text_node(result)], + ) + } else { + @respo_node.span([]) + }, + ], + ) +} diff --git a/src/main/requests.mbt b/src/main/requests.mbt new file mode 100644 index 0000000..422aea8 --- /dev/null +++ b/src/main/requests.mbt @@ -0,0 +1,104 @@ +// FFI for fetch +///| +type JSResponse + +///| +type JSJson + +///| +extern "js" fn fetch( + url : String, + options : @dom_ffi.JsObscure, +) -> @js_async.Promise[JSResponse] = + #| (url, options) => fetch(url, options) + +///| +extern "js" fn JSResponse::json(self : JSResponse) -> @js_async.Promise[JSJson] = + #| (self) => self.json() + +///| +extern "js" fn JSResponse::text(self : JSResponse) -> @js_async.Promise[String] = + #| (self) => self.text() + +///| +extern "js" fn JSResponse::status(self : JSResponse) -> Int = + #| (self) => self.status + +///| +extern "js" fn JSResponse::ok(self : JSResponse) -> Bool = + #| (self) => self.ok + +// Helper to create fetch options + +///| +extern "js" fn make_post_options(body : String) -> @dom_ffi.JsObscure = + #| (body) => ({ method: "POST", body: body, headers: { "Content-Type": "application/json" } }) + +///| +extern "js" fn make_get_options() -> @dom_ffi.JsObscure = + #| () => ({ method: "GET" }) + +// Helper to parse JSJson + +///| +extern "js" fn get_id(json : JSJson) -> String = + #| (json) => json.id + +///| +extern "js" fn get_status(json : JSJson) -> String = + #| (json) => json.status + +///| +extern "js" fn get_result(json : JSJson) -> String = + #| (json) => json.result + +///| +extern "js" fn sleep(ms : Int) -> @js_async.Promise[Unit] = + #| (ms) => new Promise(resolve => setTimeout(resolve, ms)) + +// Async function to run the query + +///| +pub async fn run_query(prompt : String) -> String raise Failure { + let body = "{\"prompt\": \"" + prompt + "\", \"mode\": \"hybrid\"}" + let options = make_post_options(body) + + // POST /query + let resp = fetch("https://moonverse.tiye.me/query", options).wait() catch { + error => raise Failure("Fetch failed: " + error.to_string()) + } + if not(resp.ok()) { + raise Failure("Failed to start query: " + resp.status().to_string()) + } + let json_resp = resp.json().wait() catch { + error => raise Failure("JSON parse failed: " + error.to_string()) + } + let id = get_id(json_resp) + + // Poll /query/{id} + while true { + let poll_resp = fetch( + "https://moonverse.tiye.me/query/" + id, + make_get_options(), + ).wait() catch { + error => raise Failure("Poll fetch failed: " + error.to_string()) + } + if not(poll_resp.ok()) { + raise Failure("Failed to poll query: " + poll_resp.status().to_string()) + } + let poll_json = poll_resp.json().wait() catch { + error => raise Failure("Poll JSON parse failed: " + error.to_string()) + } + let status = get_status(poll_json) + if status == "done" { + break get_result(poll_json) + } else if status == "error" { + raise Failure("Query failed") + } + sleep(1000).wait() catch { + _ => () + } + } else { + raise Failure("Unreachable") + } +} diff --git a/src/main/store.mbt b/src/main/store.mbt index 96f36ac..8b96e50 100644 --- a/src/main/store.mbt +++ b/src/main/store.mbt @@ -2,11 +2,20 @@ struct Store { counted : Int states : @respo.RespoStatesTree + query_result : String + query_loading : Bool + query_error : String } derive(ToJson, @json.FromJson, Eq) ///| impl Default for Store with default() -> Store { - { counted: 0, states: @respo.RespoStatesTree::default() } + { + counted: 0, + states: @respo.RespoStatesTree::default(), + query_result: "", + query_loading: false, + query_error: "", + } } ///| @@ -23,6 +32,9 @@ enum ActionOp { Increment Decrement IncTwice + StartQuery(String) + QuerySuccess(String) + QueryError(String) } ///| @@ -39,6 +51,9 @@ impl Show for ActionOp with output(self, logger) -> Unit { Increment => "Increment" Decrement => "Decrement" IncTwice => "IncTwice" + StartQuery(prompt) => "StartQuery(\{prompt})" + QuerySuccess(res) => "QuerySuccess(\{res})" + QueryError(err) => "QueryError(\{err})" } logger.write_string(s) } @@ -50,6 +65,10 @@ fn Store::update(self : Store, op : ActionOp) -> Store { Increment => { ..self, counted: self.counted + 1 } StatesChange(states) => { ..self, states: self.states.set_in(states) } Decrement => { ..self, counted: self.counted - 1 } + StartQuery(_) => + { ..self, query_loading: true, query_error: "", query_result: "" } + QuerySuccess(res) => { ..self, query_loading: false, query_result: res } + QueryError(err) => { ..self, query_loading: false, query_error: err } _ => self } } From a3b60c667dbdcba563956a5714bb71b652741ae2 Mon Sep 17 00:00:00 2001 From: tiye Date: Sat, 20 Dec 2025 00:44:56 +0800 Subject: [PATCH 2/8] workflow update --- index.html | 2 +- src/{main => }/main.mbt | 1 - src/main/counter.mbt | 88 ----------------------------------- src/{main => }/moon.pkg.json | 0 src/{main => }/query_comp.mbt | 0 src/{main => }/requests.mbt | 0 src/{main => }/store.mbt | 0 7 files changed, 1 insertion(+), 90 deletions(-) rename src/{main => }/main.mbt (96%) delete mode 100644 src/main/counter.mbt rename src/{main => }/moon.pkg.json (100%) rename src/{main => }/query_comp.mbt (100%) rename src/{main => }/requests.mbt (100%) rename src/{main => }/store.mbt (100%) diff --git a/index.html b/index.html index 5c28e5e..757339c 100644 --- a/index.html +++ b/index.html @@ -16,7 +16,7 @@
diff --git a/src/main/main.mbt b/src/main.mbt similarity index 96% rename from src/main/main.mbt rename to src/main.mbt index d3e4469..211e89b 100644 --- a/src/main/main.mbt +++ b/src/main.mbt @@ -16,7 +16,6 @@ fn view( } // @dom_ffi.log("Store to render: " + store.to_json().stringify(indent=2)) div(class_name=@respo.ui_global, style=respo_style(padding=Px(12)), [ - comp_counter(store.states.pick("counter"), store.counted), comp_query( store.states.pick("query"), store.query_loading, diff --git a/src/main/counter.mbt b/src/main/counter.mbt deleted file mode 100644 index f4258cc..0000000 --- a/src/main/counter.mbt +++ /dev/null @@ -1,88 +0,0 @@ -///| -using @respo_node { - text_node, - type RespoEvent, - type RespoNode, - type DispatchFn, - type RespoCommonError, -} - -///| -struct MainState { - counted : Int -} derive(Default, ToJson, @json.FromJson) - -///| -fn comp_counter( - states : @respo.RespoStatesTree, - global_counted : Int, -) -> RespoNode[ActionOp] { - let cursor = states.path() - let state = (states.cast_branch() : MainState) - let counted = state.counted - let on_inc = fn( - e : RespoEvent, - dispatch : DispatchFn[ActionOp], - ) -> Unit raise RespoCommonError { - @dom_ffi.warn_log("inc click: \{e}") - if e is Click(original_event~, ..) { - original_event.prevent_default() - } - dispatch.run(Increment) - dispatch.set_state(cursor, { counted: state.counted + 1 }) - } - let on_dec = fn( - e, - dispatch : DispatchFn[ActionOp], - ) -> Unit raise RespoCommonError { - @dom_ffi.warn_log("dec click: \{e}") - dispatch.run(Decrement) - dispatch.set_state(cursor, { counted: state.counted - 1 }) - } - let on_inc_twice = fn( - e : RespoEvent, - dispatch : DispatchFn[ActionOp], - ) -> Unit raise RespoCommonError { - @dom_ffi.warn_log("twice click: \{e}") - dispatch.run(IncTwice) - dispatch.set_state(cursor, { counted: state.counted + 2 }) - } - div([ - div([ - button( - inner_text="demo inc", - class_name=@respo.ui_button, - style=respo_style(margin=4 |> Px), - on_click=on_inc, - ), - button( - inner_text="demo dec", - class_name=@respo.ui_button, - style=respo_style(margin=4 |> Px), - on_click=on_dec, - ), - button( - inner_text="demo inc twice", - class_name=@respo.ui_button, - style=respo_style(margin=4 |> Px), - on_click=on_inc_twice, - ), - ]), - div([ - span( - inner_text="value is: \{counted}", - style=respo_style( - color=Hsluv(270, 100, 40), - font_family="Menlo", - font_size=counted.reinterpret_as_uint() + 10, - ), - [], - ), - ]), - div([ - "local state: \{counted}" |> text_node, - @respo_node.br(), - "global state: \{global_counted}" |> text_node, - ]), - ]) -} diff --git a/src/main/moon.pkg.json b/src/moon.pkg.json similarity index 100% rename from src/main/moon.pkg.json rename to src/moon.pkg.json diff --git a/src/main/query_comp.mbt b/src/query_comp.mbt similarity index 100% rename from src/main/query_comp.mbt rename to src/query_comp.mbt diff --git a/src/main/requests.mbt b/src/requests.mbt similarity index 100% rename from src/main/requests.mbt rename to src/requests.mbt diff --git a/src/main/store.mbt b/src/store.mbt similarity index 100% rename from src/main/store.mbt rename to src/store.mbt From 5715835553470ac41c11e9659cea2d8124626981 Mon Sep 17 00:00:00 2001 From: tiye Date: Sat, 20 Dec 2025 01:24:26 +0800 Subject: [PATCH 3/8] getting page boilerplate --- src/main.mbt | 7 +- src/query_comp.mbt | 321 ++++++++++++++++++++++++++++++++++++++------- src/requests.mbt | 5 +- src/store.mbt | 19 +-- 4 files changed, 288 insertions(+), 64 deletions(-) diff --git a/src/main.mbt b/src/main.mbt index 211e89b..9e44a1d 100644 --- a/src/main.mbt +++ b/src/main.mbt @@ -5,7 +5,7 @@ using @respo_node {div, span, button} using @css {respo_style} ///| -let app_store_key : String = "mbt-workflow" +let app_store_key : String = "moonverse-portal" ///| fn view( @@ -18,7 +18,6 @@ fn view( div(class_name=@respo.ui_global, style=respo_style(padding=Px(12)), [ comp_query( store.states.pick("query"), - store.query_loading, store.query_result, store.query_error, ), @@ -30,8 +29,10 @@ fn main { let window = @dom_ffi.window() if window.document().query_selector(".app") is Some(mount_target) { let mount_target = mount_target.reinterpret_as_node() + let mut store_0 : Store = @respo.try_load_storage(app_store_key) + store_0 = { ..store_0, states: @respo.RespoStatesTree::default() } let app : @respo.RespoApp[Store] = { - store: Ref::new(@respo.try_load_storage(app_store_key)), + store: Ref::new(store_0), mount_target, storage_key: app_store_key, } diff --git a/src/query_comp.mbt b/src/query_comp.mbt index 3821ae9..03f154e 100644 --- a/src/query_comp.mbt +++ b/src/query_comp.mbt @@ -1,59 +1,292 @@ ///| struct QueryState { input_val : String + loading : Bool } derive(Default, ToJson, @json.FromJson) ///| fn comp_query( states : @respo.RespoStatesTree, - loading : Bool, result : String, error : String, ) -> @respo_node.RespoNode[ActionOp] { let cursor = states.path() let state = (states.cast_branch() : QueryState) - @respo_node.div( - class_name="query-comp", - style=@css.respo_style(padding=Px(12)), - [ - @respo_node.div([ - @respo_node.input( - value=state.input_val, - placeholder="Enter prompt...", - on_input=fn(e, dispatch) { - match e { - Input(value~, ..) => - dispatch.set_state(cursor, { input_val: value }) - _ => () - } - }, - style=@css.respo_style(width=Px(300), margin_right=Px(8)), - ), - @respo_node.button( - inner_text=if loading { "Loading..." } else { "Submit" }, - attrs={ "disabled": if loading { "true" } else { "false" } }, - on_click=fn(_e, dispatch) { - if not(loading) && not(state.input_val.is_empty()) { - dispatch.run(StartQuery(state.input_val)) - } - }, - ), - ]), - if not(error.is_empty()) { - @respo_node.div(style=@css.respo_style(color=Red, margin_top=Px(8)), [ - @respo_node.text_node(error), - ]) - } else { - @respo_node.span([]) - }, - if not(result.is_empty()) { - @respo_node.div( - style=@css.respo_style(margin_top=Px(8), white_space="pre-wrap"), - [@respo_node.text_node(result)], + + ///| + fn start_query_request( + prompt : String, + dispatch : @respo_node.DispatchFn[ActionOp], + ) -> Unit { + println("start_query_request: prompt = \{prompt}") + // 设置 loading = true + dispatch.set_state(cursor, QueryState::{ input_val: prompt, loading: true }) catch { + _ => () + } + let _ = @js_async.Promise::from_async(async fn() { + println("Inside async: starting POST /query") + // POST /query + let body = "{\"prompt\": \"" + prompt + "\", \"mode\": \"hybrid\"}" + println("Request body: \{body}") + let resp = fetch("http://localhost:8080/query", make_post_options(body)).wait() catch { + error => { + println("Fetch error: \{error}") + dispatch.set_state(cursor, QueryState::{ + input_val: prompt, + loading: false, + }) + dispatch.run(QueryError("Fetch failed: " + error.to_string())) + return + } + } + println("Response status: \{resp.status()}, ok: \{resp.ok()}") + if not(resp.ok()) { + dispatch.set_state(cursor, QueryState::{ + input_val: prompt, + loading: false, + }) + dispatch.run( + QueryError("Failed to start query: " + resp.status().to_string()), ) - } else { - @respo_node.span([]) - }, - ], - ) + return + } + let json_resp = resp.json().wait() catch { + error => { + println("JSON parse error: \{error}") + dispatch.set_state(cursor, QueryState::{ + input_val: prompt, + loading: false, + }) + dispatch.run(QueryError("JSON parse failed: " + error.to_string())) + return + } + } + let id = get_id(json_resp) + println("Got task id: \{id}") + + // 轮询 /query/{id} + while true { + println("Polling /query/\{id}") + let poll_resp = fetch( + "http://localhost:8080/query/" + id, + make_get_options(), + ).wait() catch { + error => { + println("Poll fetch error: \{error}") + dispatch.set_state(cursor, QueryState::{ + input_val: prompt, + loading: false, + }) + dispatch.run(QueryError("Poll fetch failed: " + error.to_string())) + return + } + } + if not(poll_resp.ok()) { + dispatch.set_state(cursor, QueryState::{ + input_val: prompt, + loading: false, + }) + dispatch.run( + QueryError( + "Failed to poll query: " + poll_resp.status().to_string(), + ), + ) + return + } + let poll_json = poll_resp.json().wait() catch { + error => { + println("Poll JSON parse error: \{error}") + dispatch.set_state(cursor, QueryState::{ + input_val: prompt, + loading: false, + }) + dispatch.run( + QueryError("Poll JSON parse failed: " + error.to_string()), + ) + return + } + } + let status = get_status(poll_json) + println("Poll status: \{status}") + if status == "done" { + let result = get_result(poll_json) + println("Query done, result length: \{result.length()}") + dispatch.set_state(cursor, QueryState::{ + input_val: prompt, + loading: false, + }) + dispatch.run(QuerySuccess(result)) + break + } else if status == "error" { + println("Query failed on server") + dispatch.set_state(cursor, QueryState::{ + input_val: prompt, + loading: false, + }) + dispatch.run(QueryError("Query failed on server")) + break + } + sleep(1000).wait() catch { + _ => () + } + } + }) + println("start_query_request: after async") + } + + @respo_node.div(class_name=style_query_container, [ + // 标题 + @respo_node.div(class_name=style_title, [ + @respo_node.text_node("MoonBit Query"), + ]), + // 输入区域 + @respo_node.div(class_name=style_input_row, [ + @respo_node.input( + value=state.input_val, + placeholder="Enter your question about MoonBit...", + class_name=style_input, + on_input=fn(e, dispatch) { + match e { + Input(value~, ..) => + dispatch.set_state(cursor, { ..state, input_val: value }) + _ => () + } + }, + ), + @respo_node.button( + inner_text=if state.loading { "Loading..." } else { "Submit" }, + class_name=style_button, + on_click=fn(_e, dispatch) { + if not(state.loading) && not(state.input_val.is_empty()) { + let prompt = state.input_val + println("Submit clicked, prompt: \{prompt}") + dispatch.run(StartQuery(prompt)) + start_query_request(prompt, dispatch) + } + }, + ), + ]), + // 错误信息 + if not(error.is_empty()) { + @respo_node.div(class_name=style_error, [@respo_node.text_node(error)]) + } else { + @respo_node.span([]) + }, + // 结果显示 + if not(result.is_empty()) { + @respo_node.div(class_name=style_result, [@respo_node.text_node(result)]) + } else if state.loading { + @respo_node.div(class_name=style_loading, [ + @respo_node.text_node("Thinking..."), + ]) + } else { + @respo_node.span([]) + }, + ]) } + +// Static styles + +///| +let style_query_container : String = @respo_node.static_style([ + ( + "&", + @css.respo_style( + padding=Px(20), + max_width=Px(800), + margin=Auto, + font_family="system-ui, -apple-system, sans-serif", + ), + ), +]) + +///| +let style_title : String = @respo_node.static_style([ + ( + "&", + @css.respo_style( + font_size=24, + font_weight="bold", + margin_bottom=Px(16), + color=Hsl(220, 13, 18), + ), + ), +]) + +///| +let style_input_row : String = @respo_node.static_style([ + ("&", @css.respo_style(display=Flex, gap=Px(8), margin_bottom=Px(16))), +]) + +///| +let style_input : String = @respo_node.static_style([ + ( + "&", + @css.respo_style( + flex=1.0, + padding=Px(12), + font_size=16, + border=@css.CssBorder::new(width=1.0, style=Solid, color=Hsl(220, 13, 80)), + border_radius=6.0, + outline=@css.CssOutline::None, + ), + ), +]) + +///| +let style_button : String = @respo_node.static_style([ + ( + "&", + @css.respo_style( + padding_left=Px(24), + padding_right=Px(24), + padding_top=Px(12), + padding_bottom=Px(12), + font_size=16, + font_weight="600", + background_color=Hsl(220, 90, 56), + color=White, + border_width=Px(0), + border_radius=6.0, + cursor=Pointer, + ), + ), +]) + +///| +let style_error : String = @respo_node.static_style([ + ( + "&", + @css.respo_style( + padding=Px(12), + margin_bottom=Px(16), + background_color=Hsl(0, 80, 95), + color=Hsl(0, 70, 40), + border_radius=6.0, + border=@css.CssBorder::new(width=1.0, style=Solid, color=Hsl(0, 70, 80)), + ), + ), +]) + +///| +let style_result : String = @respo_node.static_style([ + ( + "&", + @css.respo_style( + padding=Px(16), + background_color=Hsl(220, 14, 96), + border_radius=8.0, + white_space="pre-wrap", + line_height=Em(1.6), + font_size=15, + ), + ), +]) + +///| +let style_loading : String = @respo_node.static_style([ + ( + "&", + @css.respo_style(padding=Px(16), color=Hsl(220, 13, 50), font_style=Italic), + ), +]) diff --git a/src/requests.mbt b/src/requests.mbt index 422aea8..7cec341 100644 --- a/src/requests.mbt +++ b/src/requests.mbt @@ -1,4 +1,5 @@ // FFI for fetch + ///| type JSResponse @@ -64,7 +65,7 @@ pub async fn run_query(prompt : String) -> String raise Failure { let options = make_post_options(body) // POST /query - let resp = fetch("https://moonverse.tiye.me/query", options).wait() catch { + let resp = fetch("http://localhost:8080/query", options).wait() catch { error => raise Failure("Fetch failed: " + error.to_string()) } if not(resp.ok()) { @@ -78,7 +79,7 @@ pub async fn run_query(prompt : String) -> String raise Failure { // Poll /query/{id} while true { let poll_resp = fetch( - "https://moonverse.tiye.me/query/" + id, + "http://localhost:8080/query/" + id, make_get_options(), ).wait() catch { error => raise Failure("Poll fetch failed: " + error.to_string()) diff --git a/src/store.mbt b/src/store.mbt index 8b96e50..f4dd1e4 100644 --- a/src/store.mbt +++ b/src/store.mbt @@ -3,7 +3,6 @@ struct Store { counted : Int states : @respo.RespoStatesTree query_result : String - query_loading : Bool query_error : String } derive(ToJson, @json.FromJson, Eq) @@ -13,7 +12,6 @@ impl Default for Store with default() -> Store { counted: 0, states: @respo.RespoStatesTree::default(), query_result: "", - query_loading: false, query_error: "", } } @@ -29,9 +27,6 @@ struct Task { ///| enum ActionOp { StatesChange(@respo.RespoUpdateState) - Increment - Decrement - IncTwice StartQuery(String) QuerySuccess(String) QueryError(String) @@ -48,9 +43,7 @@ impl Show for ActionOp with output(self, logger) -> Unit { StatesChange(state) => "StatesChange(\{state.cursor} \{state.backup.to_json()})" // Intent(_intent) => "Intent(...)" - Increment => "Increment" - Decrement => "Decrement" - IncTwice => "IncTwice" + StartQuery(prompt) => "StartQuery(\{prompt})" QuerySuccess(res) => "QuerySuccess(\{res})" QueryError(err) => "QueryError(\{err})" @@ -62,13 +55,9 @@ impl Show for ActionOp with output(self, logger) -> Unit { /// update store immutably fn Store::update(self : Store, op : ActionOp) -> Store { match op { - Increment => { ..self, counted: self.counted + 1 } StatesChange(states) => { ..self, states: self.states.set_in(states) } - Decrement => { ..self, counted: self.counted - 1 } - StartQuery(_) => - { ..self, query_loading: true, query_error: "", query_result: "" } - QuerySuccess(res) => { ..self, query_loading: false, query_result: res } - QueryError(err) => { ..self, query_loading: false, query_error: err } - _ => self + StartQuery(_) => { ..self, query_error: "", query_result: "" } + QuerySuccess(res) => { ..self, query_result: res } + QueryError(err) => { ..self, query_error: err } } } From ba1142933419e0a9456acc25d44f88d2d7684ac5 Mon Sep 17 00:00:00 2001 From: tiye Date: Sat, 20 Dec 2025 01:42:18 +0800 Subject: [PATCH 4/8] gpt figured out tricky fix --- src/query_comp.mbt | 124 +++++++++------------------------------------ src/requests.mbt | 53 +++++++++++++++++++ 2 files changed, 76 insertions(+), 101 deletions(-) diff --git a/src/query_comp.mbt b/src/query_comp.mbt index 03f154e..228784b 100644 --- a/src/query_comp.mbt +++ b/src/query_comp.mbt @@ -23,115 +23,37 @@ fn comp_query( dispatch.set_state(cursor, QueryState::{ input_val: prompt, loading: true }) catch { _ => () } - let _ = @js_async.Promise::from_async(async fn() { - println("Inside async: starting POST /query") - // POST /query - let body = "{\"prompt\": \"" + prompt + "\", \"mode\": \"hybrid\"}" - println("Request body: \{body}") - let resp = fetch("http://localhost:8080/query", make_post_options(body)).wait() catch { - error => { - println("Fetch error: \{error}") - dispatch.set_state(cursor, QueryState::{ - input_val: prompt, - loading: false, - }) - dispatch.run(QueryError("Fetch failed: " + error.to_string())) - return - } - } - println("Response status: \{resp.status()}, ok: \{resp.ok()}") - if not(resp.ok()) { + // 使用纯 JS 实现的异步请求 + run_query_js( + prompt, + fn(result) { + println("Query success, result length: \{result.length()}") dispatch.set_state(cursor, QueryState::{ input_val: prompt, loading: false, - }) - dispatch.run( - QueryError("Failed to start query: " + resp.status().to_string()), - ) - return - } - let json_resp = resp.json().wait() catch { - error => { - println("JSON parse error: \{error}") - dispatch.set_state(cursor, QueryState::{ - input_val: prompt, - loading: false, - }) - dispatch.run(QueryError("JSON parse failed: " + error.to_string())) - return - } - } - let id = get_id(json_resp) - println("Got task id: \{id}") - - // 轮询 /query/{id} - while true { - println("Polling /query/\{id}") - let poll_resp = fetch( - "http://localhost:8080/query/" + id, - make_get_options(), - ).wait() catch { - error => { - println("Poll fetch error: \{error}") - dispatch.set_state(cursor, QueryState::{ - input_val: prompt, - loading: false, - }) - dispatch.run(QueryError("Poll fetch failed: " + error.to_string())) - return - } + }) catch { + _ => () } - if not(poll_resp.ok()) { - dispatch.set_state(cursor, QueryState::{ - input_val: prompt, - loading: false, - }) - dispatch.run( - QueryError( - "Failed to poll query: " + poll_resp.status().to_string(), - ), - ) - return + dispatch.run(QuerySuccess(result)) catch { + _ => () } - let poll_json = poll_resp.json().wait() catch { - error => { - println("Poll JSON parse error: \{error}") - dispatch.set_state(cursor, QueryState::{ - input_val: prompt, - loading: false, - }) - dispatch.run( - QueryError("Poll JSON parse failed: " + error.to_string()), - ) - return - } - } - let status = get_status(poll_json) - println("Poll status: \{status}") - if status == "done" { - let result = get_result(poll_json) - println("Query done, result length: \{result.length()}") - dispatch.set_state(cursor, QueryState::{ - input_val: prompt, - loading: false, - }) - dispatch.run(QuerySuccess(result)) - break - } else if status == "error" { - println("Query failed on server") - dispatch.set_state(cursor, QueryState::{ - input_val: prompt, - loading: false, - }) - dispatch.run(QueryError("Query failed on server")) - break + @respo.mark_need_rerender() + }, + fn(err) { + println("Query error: \{err}") + dispatch.set_state(cursor, QueryState::{ + input_val: prompt, + loading: false, + }) catch { + _ => () } - sleep(1000).wait() catch { + dispatch.run(QueryError(err)) catch { _ => () } - } - }) - println("start_query_request: after async") + @respo.mark_need_rerender() + }, + ) + println("start_query_request: after run_query_js") } @respo_node.div(class_name=style_query_container, [ diff --git a/src/requests.mbt b/src/requests.mbt index 7cec341..fbc37fb 100644 --- a/src/requests.mbt +++ b/src/requests.mbt @@ -57,6 +57,59 @@ extern "js" fn get_result(json : JSJson) -> String = extern "js" fn sleep(ms : Int) -> @js_async.Promise[Unit] = #| (ms) => new Promise(resolve => setTimeout(resolve, ms)) +///| +/// Helper to schedule an async function to run on the next tick +extern "js" fn schedule_async(promise : @js_async.Promise[Unit]) -> Unit = + #| (p) => { p.then(() => {}).catch(e => console.error("Async error:", e)); } + +///| +/// Run query using pure JS async - bypassing MoonBit async issues +extern "js" fn run_query_js( + prompt : String, + on_success : (String) -> Unit, + on_error : (String) -> Unit, +) -> Unit = + #| async (prompt, onSuccess, onError) => { + #| try { + #| console.log("run_query_js: starting with prompt:", prompt); + #| const body = JSON.stringify({ type: "hybrid", query: prompt }); + #| const resp = await fetch("http://localhost:8080/query", { + #| method: "POST", + #| headers: { "Content-Type": "application/json" }, + #| body + #| }); + #| console.log("run_query_js: POST response status:", resp.status); + #| if (!resp.ok) { + #| onError("Failed to start query: " + resp.status); + #| return; + #| } + #| const json = await resp.json(); + #| const id = json.id; + #| console.log("run_query_js: got task id:", id); + #| while (true) { + #| console.log("run_query_js: polling /query/" + id); + #| const pollResp = await fetch("http://localhost:8080/query/" + id); + #| if (!pollResp.ok) { + #| onError("Failed to poll: " + pollResp.status); + #| return; + #| } + #| const pollJson = await pollResp.json(); + #| console.log("run_query_js: poll status:", pollJson.status); + #| if (pollJson.status === "done") { + #| onSuccess(pollJson.content || pollJson.result || ""); + #| return; + #| } else if (pollJson.status === "error") { + #| onError(pollJson.error || "Query failed"); + #| return; + #| } + #| await new Promise(r => setTimeout(r, 1000)); + #| } + #| } catch (e) { + #| console.error("run_query_js error:", e); + #| onError(e.toString()); + #| } + #| } + // Async function to run the query ///| From 240373d21560e4f88ebe6d7de2f8b1b2df62e3be Mon Sep 17 00:00:00 2001 From: tiye Date: Sat, 20 Dec 2025 12:26:14 +0800 Subject: [PATCH 5/8] get page work with domain --- moon.mod.json | 3 ++- src/moon.pkg.json | 1 + src/query_comp.mbt | 2 +- src/requests.mbt | 8 ++++++-- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/moon.mod.json b/moon.mod.json index 7304008..75e99f0 100644 --- a/moon.mod.json +++ b/moon.mod.json @@ -5,7 +5,8 @@ "tiye/respo": "0.2.3", "tiye/dom-ffi": "0.2.3", "tiye/respo_css": "0.1.4", - "moonbitlang/async": "0.15.0" + "moonbitlang/async": "0.15.0", + "tiye/respo-markdown": "0.1.2" }, "readme": "README.md", "repository": "", diff --git a/src/moon.pkg.json b/src/moon.pkg.json index f8d9919..52ff3c7 100644 --- a/src/moon.pkg.json +++ b/src/moon.pkg.json @@ -11,6 +11,7 @@ }, { "path": "tiye/dom-ffi", "alias": "dom_ffi" }, { "path": "tiye/respo_css", "alias": "css" }, + { "path": "tiye/respo-markdown", "alias": "markdown" }, { "path": "moonbitlang/async/js_async", "alias": "js_async" } ] } diff --git a/src/query_comp.mbt b/src/query_comp.mbt index 228784b..b524609 100644 --- a/src/query_comp.mbt +++ b/src/query_comp.mbt @@ -96,7 +96,7 @@ fn comp_query( }, // 结果显示 if not(result.is_empty()) { - @respo_node.div(class_name=style_result, [@respo_node.text_node(result)]) + @respo_node.div(class_name=style_result, [@markdown.comp_md_block(result)]) } else if state.loading { @respo_node.div(class_name=style_loading, [ @respo_node.text_node("Thinking..."), diff --git a/src/requests.mbt b/src/requests.mbt index fbc37fb..c9d83d9 100644 --- a/src/requests.mbt +++ b/src/requests.mbt @@ -71,9 +71,13 @@ extern "js" fn run_query_js( ) -> Unit = #| async (prompt, onSuccess, onError) => { #| try { + #| const params = new URLSearchParams(window.location.search); + #| const isDev = params.get("mode") === "dev"; + #| const baseUrl = isDev ? "http://localhost:8080" : "https://moonverse-api.tiye.me"; + #| console.log("run_query_js: using baseUrl:", baseUrl, "isDev:", isDev); #| console.log("run_query_js: starting with prompt:", prompt); #| const body = JSON.stringify({ type: "hybrid", query: prompt }); - #| const resp = await fetch("http://localhost:8080/query", { + #| const resp = await fetch(baseUrl + "/query", { #| method: "POST", #| headers: { "Content-Type": "application/json" }, #| body @@ -88,7 +92,7 @@ extern "js" fn run_query_js( #| console.log("run_query_js: got task id:", id); #| while (true) { #| console.log("run_query_js: polling /query/" + id); - #| const pollResp = await fetch("http://localhost:8080/query/" + id); + #| const pollResp = await fetch(baseUrl + "/query/" + id); #| if (!pollResp.ok) { #| onError("Failed to poll: " + pollResp.status); #| return; From 27d6db1340257bdb5914e561b221aff4f3f28adb Mon Sep 17 00:00:00 2001 From: tiye Date: Sat, 20 Dec 2025 14:58:07 +0800 Subject: [PATCH 6/8] add copy markdown button --- src/query_comp.mbt | 61 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/src/query_comp.mbt b/src/query_comp.mbt index b524609..d3e34a7 100644 --- a/src/query_comp.mbt +++ b/src/query_comp.mbt @@ -4,6 +4,34 @@ struct QueryState { loading : Bool } derive(Default, ToJson, @json.FromJson) +///| +/// Copy helper keeps a raw markdown record for debugging in UI. +extern "js" fn copy_to_clipboard(text : String) -> Unit = + #| (text) => { + #| const safeText = text ?? ""; + #| if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { + #| navigator.clipboard.writeText(safeText).catch(err => { + #| console.warn("Clipboard write failed", err); + #| }); + #| return; + #| } + #| const textarea = document.createElement("textarea"); + #| textarea.value = safeText; + #| textarea.style.position = "fixed"; + #| textarea.style.opacity = "0"; + #| textarea.style.pointerEvents = "none"; + #| document.body.appendChild(textarea); + #| textarea.focus(); + #| textarea.select(); + #| try { + #| document.execCommand("copy"); + #| } catch (err) { + #| console.error("execCommand copy failed", err); + #| } finally { + #| document.body.removeChild(textarea); + #| } + #| } + ///| fn comp_query( states : @respo.RespoStatesTree, @@ -104,6 +132,19 @@ fn comp_query( } else { @respo_node.span([]) }, + // 拷贝按钮 + if not(result.is_empty()) { + @respo_node.button( + inner_text="Copy Raw Markdown", + class_name=style_copy_button, + on_click=fn(_e, _dispatch) { + copy_to_clipboard(result) + println("Copied markdown to clipboard, length: \{result.length()}") + }, + ) + } else { + @respo_node.span([]) + }, ]) } @@ -212,3 +253,23 @@ let style_loading : String = @respo_node.static_style([ @css.respo_style(padding=Px(16), color=Hsl(220, 13, 50), font_style=Italic), ), ]) + +///| +let style_copy_button : String = @respo_node.static_style([ + ( + "&", + @css.respo_style( + margin_top=Px(12), + padding_left=Px(16), + padding_right=Px(16), + padding_top=Px(8), + padding_bottom=Px(8), + font_size=13, + background_color=Hsl(220, 10, 90), + color=Hsl(220, 13, 30), + border_width=Px(0), + border_radius=4.0, + cursor=Pointer, + ), + ), +]) From 2522cd90db668c311661bbfafe9d10e96a9ad3c5 Mon Sep 17 00:00:00 2001 From: tiye Date: Sat, 20 Dec 2025 16:23:49 +0800 Subject: [PATCH 7/8] change to sidebar layout --- moon.mod.json | 8 +- src/main.mbt | 38 ++- src/query_comp.mbt | 568 +++++++++++++++++++++++++++++++++++---------- src/store.mbt | 84 +++++-- 4 files changed, 527 insertions(+), 171 deletions(-) diff --git a/moon.mod.json b/moon.mod.json index 75e99f0..031c6ef 100644 --- a/moon.mod.json +++ b/moon.mod.json @@ -2,11 +2,11 @@ "name": "respo/app", "version": "0.1.0", "deps": { - "tiye/respo": "0.2.3", + "tiye/respo": "0.2.5", "tiye/dom-ffi": "0.2.3", - "tiye/respo_css": "0.1.4", + "tiye/respo_css": "0.1.5", "moonbitlang/async": "0.15.0", - "tiye/respo-markdown": "0.1.2" + "tiye/respo-markdown": "0.1.3" }, "readme": "README.md", "repository": "", @@ -15,4 +15,4 @@ "description": "", "source": "src", "preferred-target": "js" -} \ No newline at end of file +} diff --git a/src/main.mbt b/src/main.mbt index 9e44a1d..d8c08ec 100644 --- a/src/main.mbt +++ b/src/main.mbt @@ -1,9 +1,3 @@ -///| -using @respo_node {div, span, button} - -///| -using @css {respo_style} - ///| let app_store_key : String = "moonverse-portal" @@ -15,13 +9,7 @@ fn view( raise @respo_node.RespoCommonError("guarded") } // @dom_ffi.log("Store to render: " + store.to_json().stringify(indent=2)) - div(class_name=@respo.ui_global, style=respo_style(padding=Px(12)), [ - comp_query( - store.states.pick("query"), - store.query_result, - store.query_error, - ), - ]) + comp_app(store.states.pick("app"), store) } ///| @@ -45,19 +33,19 @@ fn main { app.store.val = app.store.val.update(op) match op { StartQuery(prompt) => { - let _ = @js_async.Promise::from_async(async fn() { - try { - let result = run_query(prompt) - app.store.val = app.store.val.update(QuerySuccess(result)) + // Get the id that was just created + let id = app.store.val.next_id - 1 + run_query_js( + prompt, + fn(result) { + app.store.val = app.store.val.update(QuerySuccess(id, result)) + @respo.mark_need_rerender() + }, + fn(err) { + app.store.val = app.store.val.update(QueryError(id, err)) @respo.mark_need_rerender() - } catch { - Failure(msg) => { - app.store.val = app.store.val.update(QueryError(msg)) - @respo.mark_need_rerender() - } - } - }) - () + }, + ) } _ => () } diff --git a/src/query_comp.mbt b/src/query_comp.mbt index d3e34a7..25a7556 100644 --- a/src/query_comp.mbt +++ b/src/query_comp.mbt @@ -1,7 +1,6 @@ ///| struct QueryState { input_val : String - loading : Bool } derive(Default, ToJson, @json.FromJson) ///| @@ -33,176 +32,441 @@ extern "js" fn copy_to_clipboard(text : String) -> Unit = #| } ///| -fn comp_query( +fn comp_app( states : @respo.RespoStatesTree, - result : String, - error : String, + store : Store, ) -> @respo_node.RespoNode[ActionOp] { - let cursor = states.path() - let state = (states.cast_branch() : QueryState) + @respo_node.div(class_name=style_app_container, [ + // 左侧边栏 + comp_sidebar(store), + // 右侧主区域 + comp_main_area(states, store), + ]) +} - ///| - fn start_query_request( - prompt : String, - dispatch : @respo_node.DispatchFn[ActionOp], - ) -> Unit { - println("start_query_request: prompt = \{prompt}") - // 设置 loading = true - dispatch.set_state(cursor, QueryState::{ input_val: prompt, loading: true }) catch { - _ => () - } - // 使用纯 JS 实现的异步请求 - run_query_js( - prompt, - fn(result) { - println("Query success, result length: \{result.length()}") - dispatch.set_state(cursor, QueryState::{ - input_val: prompt, - loading: false, - }) catch { - _ => () - } - dispatch.run(QuerySuccess(result)) catch { - _ => () - } - @respo.mark_need_rerender() - }, - fn(err) { - println("Query error: \{err}") - dispatch.set_state(cursor, QueryState::{ - input_val: prompt, - loading: false, - }) catch { - _ => () - } - dispatch.run(QueryError(err)) catch { - _ => () - } - @respo.mark_need_rerender() - }, - ) - println("start_query_request: after run_query_js") +///| +fn comp_sidebar(store : Store) -> @respo_node.RespoNode[ActionOp] { + @respo_node.div(class_name=style_sidebar, [ + // 侧边栏标题和新建按钮 + @respo_node.div(class_name=style_sidebar_header, [ + @respo_node.div(class_name=style_sidebar_title, [ + @respo_node.text_node("History"), + ]), + @respo_node.button(inner_text="+ New", class_name=style_new_button, on_click=fn( + _e, + dispatch, + ) { + dispatch.run(NewQuery) + }), + ]), + // 历史记录列表 + @respo_node.div( + class_name=style_history_list, + store.history + .rev_iter() + .map(fn(item) { comp_history_item(item, store.current_id) }) + .collect(), + ), + ]) +} + +///| +fn comp_history_item( + item : HistoryItem, + current_id : Int?, +) -> @respo_node.RespoNode[ActionOp] { + let is_selected = current_id == Some(item.id) + let preview = if item.query.length() > 30 { + (try! item.query[:30]).to_string() + "..." + } else { + item.query } + @respo_node.div( + class_name=if is_selected { + style_history_item_selected + } else { + style_history_item + }, + on_click=fn(_e, dispatch) { dispatch.run(SelectQuery(item.id)) }, + [ + @respo_node.div(class_name=style_history_item_content, [ + @respo_node.div(class_name=style_history_preview, [ + @respo_node.text_node(preview), + ]), + if item.loading { + @respo_node.div(class_name=style_history_status, [ + @respo_node.text_node("⏳"), + ]) + } else if not(item.error.is_empty()) { + @respo_node.div(class_name=style_history_status_error, [ + @respo_node.text_node("❌"), + ]) + } else { + @respo_node.div(class_name=style_history_status_ok, [ + @respo_node.text_node("✓"), + ]) + }, + ]), + @respo_node.button(inner_text="×", class_name=style_delete_button, on_click=fn( + _e, + dispatch, + ) { + dispatch.run(DeleteQuery(item.id)) + }), + ], + ) +} - @respo_node.div(class_name=style_query_container, [ - // 标题 - @respo_node.div(class_name=style_title, [ - @respo_node.text_node("MoonBit Query"), +///| +fn comp_main_area( + states : @respo.RespoStatesTree, + store : Store, +) -> @respo_node.RespoNode[ActionOp] { + match store.current_id { + None => comp_new_query(states) + Some(id) => + match store.history.iter().find_first(fn(item) { item.id == id }) { + Some(item) => comp_query_detail(item) + None => comp_new_query(states) + } + } +} + +///| +fn comp_new_query( + states : @respo.RespoStatesTree, +) -> @respo_node.RespoNode[ActionOp] { + let cursor = states.path() + let state = (states.cast_branch() : QueryState) + @respo_node.div(class_name=style_main_area, [ + @respo_node.div(class_name=style_new_query_container, [ + @respo_node.div(class_name=style_main_title, [ + @respo_node.text_node("MoonBit Query"), + ]), + @respo_node.div(class_name=style_input_container, [ + @respo_node.textarea( + value=state.input_val, + placeholder="Enter your question about MoonBit...", + class_name=style_textarea, + on_input=fn(e, dispatch) { + match e { + Input(value~, ..) => + dispatch.set_state(cursor, QueryState::{ input_val: value }) + _ => () + } + }, + ), + ]), + @respo_node.div(class_name=style_button_row, [ + @respo_node.button( + inner_text="Submit", + class_name=style_submit_button, + on_click=fn(_e, dispatch) { + if not(state.input_val.is_empty()) { + let prompt = state.input_val + dispatch.run(StartQuery(prompt)) + // Clear input after submit + dispatch.set_state(cursor, QueryState::{ input_val: "" }) catch { + _ => () + } + } + }, + ), + ]), ]), - // 输入区域 - @respo_node.div(class_name=style_input_row, [ - @respo_node.input( - value=state.input_val, - placeholder="Enter your question about MoonBit...", - class_name=style_input, - on_input=fn(e, dispatch) { - match e { - Input(value~, ..) => - dispatch.set_state(cursor, { ..state, input_val: value }) - _ => () - } - }, - ), - @respo_node.button( - inner_text=if state.loading { "Loading..." } else { "Submit" }, - class_name=style_button, - on_click=fn(_e, dispatch) { - if not(state.loading) && not(state.input_val.is_empty()) { - let prompt = state.input_val - println("Submit clicked, prompt: \{prompt}") - dispatch.run(StartQuery(prompt)) - start_query_request(prompt, dispatch) - } - }, - ), + ]) +} + +///| +fn comp_query_detail(item : HistoryItem) -> @respo_node.RespoNode[ActionOp] { + @respo_node.div(class_name=style_main_area, [ + // 显示查询内容(只读) + @respo_node.div(class_name=style_query_display, [ + @respo_node.div(class_name=style_query_label, [ + @respo_node.text_node("Query:"), + ]), + @respo_node.div(class_name=style_query_text, [ + @respo_node.text_node(item.query), + ]), ]), - // 错误信息 - if not(error.is_empty()) { - @respo_node.div(class_name=style_error, [@respo_node.text_node(error)]) - } else { - @respo_node.span([]) - }, - // 结果显示 - if not(result.is_empty()) { - @respo_node.div(class_name=style_result, [@markdown.comp_md_block(result)]) - } else if state.loading { + // 结果或加载状态 + if item.loading { @respo_node.div(class_name=style_loading, [ @respo_node.text_node("Thinking..."), ]) - } else { - @respo_node.span([]) - }, - // 拷贝按钮 - if not(result.is_empty()) { - @respo_node.button( - inner_text="Copy Raw Markdown", - class_name=style_copy_button, - on_click=fn(_e, _dispatch) { - copy_to_clipboard(result) - println("Copied markdown to clipboard, length: \{result.length()}") - }, - ) + } else if not(item.error.is_empty()) { + @respo_node.div(class_name=style_error, [ + @respo_node.text_node(item.error), + ]) + } else if not(item.result.is_empty()) { + @respo_node.div(class_name=style_result_container, [ + @respo_node.div(class_name=style_result, [ + @markdown.comp_md_block(item.result), + ]), + @respo_node.button( + inner_text="Copy Raw Markdown", + class_name=style_copy_button, + on_click=fn(_e, _dispatch) { + copy_to_clipboard(item.result) + println("Copied markdown to clipboard") + }, + ), + ]) } else { @respo_node.span([]) }, ]) } -// Static styles +// ============= Styles ============= ///| -let style_query_container : String = @respo_node.static_style([ +let style_app_container : String = @respo_node.static_style([ ( "&", @css.respo_style( - padding=Px(20), - max_width=Px(800), - margin=Auto, + display=Flex, + height=Vh(100), font_family="system-ui, -apple-system, sans-serif", ), ), ]) ///| -let style_title : String = @respo_node.static_style([ +let style_sidebar : String = @respo_node.static_style([ + ( + "&", + @css.respo_style( + width=Px(280), + min_width=Px(280), + background_color=Hsl(220, 14, 96), + display=Flex, + flex_direction=Column, + ) + .add("border-right", "1px solid hsl(220, 13%, 88%)") + .add("overflow-y", "auto"), + ), +]) + +///| +let style_sidebar_header : String = @respo_node.static_style([ + ( + "&", + @css.respo_style( + padding=Px(16), + display=Flex, + justify_content=SpaceBetween, + align_items=Center, + ).add("border-bottom", "1px solid hsl(220, 13%, 88%)"), + ), +]) + +///| +let style_sidebar_title : String = @respo_node.static_style([ + ( + "&", + @css.respo_style(font_size=16, font_weight="600", color=Hsl(220, 13, 30)), + ), +]) + +///| +let style_new_button : String = @respo_node.static_style([ + ( + "&", + @css.respo_style( + padding_left=Px(12), + padding_right=Px(12), + padding_top=Px(6), + padding_bottom=Px(6), + font_size=14, + background_color=Hsl(220, 90, 56), + color=White, + border_width=Px(0), + border_radius=4.0, + cursor=Pointer, + ), + ), +]) + +///| +let style_history_list : String = @respo_node.static_style([ + ("&", @css.respo_style(flex=1.0, padding=Px(8)).add("overflow-y", "auto")), +]) + +///| +let style_history_item : String = @respo_node.static_style([ + ( + "&", + @css.respo_style( + display=Flex, + align_items=Center, + padding=Px(10), + margin_bottom=Px(4), + border_radius=6.0, + cursor=Pointer, + background_color=Transparent, + ), + ), + ("&:hover", @css.respo_style(background_color=Hsl(220, 14, 92))), +]) + +///| +let style_history_item_selected : String = @respo_node.static_style([ + ( + "&", + @css.respo_style( + display=Flex, + align_items=Center, + padding=Px(10), + margin_bottom=Px(4), + border_radius=6.0, + cursor=Pointer, + background_color=Hsl(220, 90, 95), + border=@css.CssBorder::new(width=1.0, style=Solid, color=Hsl(220, 90, 56)), + ), + ), +]) + +///| +let style_history_item_content : String = @respo_node.static_style([ + ( + "&", + @css.respo_style(flex=1.0, display=Flex, align_items=Center, gap=Px(8)).add( + "overflow", "hidden", + ), + ), +]) + +///| +let style_history_preview : String = @respo_node.static_style([ + ( + "&", + @css.respo_style( + font_size=14, + color=Hsl(220, 13, 30), + overflow=Hidden, + white_space="nowrap", + ) + .add("text-overflow", "ellipsis") + .add("flex", "1 1 0") + .add("min-width", "0"), + ), +]) + +///| +let style_history_status : String = @respo_node.static_style([ + ("&", @css.respo_style(font_size=12)), +]) + +///| +let style_history_status_ok : String = @respo_node.static_style([ + ("&", @css.respo_style(font_size=12, color=Hsl(120, 60, 40))), +]) + +///| +let style_history_status_error : String = @respo_node.static_style([ + ("&", @css.respo_style(font_size=12, color=Hsl(0, 70, 50))), +]) + +///| +let style_delete_button : String = @respo_node.static_style([ + ( + "&", + @css.respo_style( + padding_left=Px(8), + padding_right=Px(8), + padding_top=Px(4), + padding_bottom=Px(4), + font_size=16, + background_color=Transparent, + color=Hsl(220, 13, 60), + border_width=Px(0), + cursor=Pointer, + opacity=0.6, + ), + ), + ("&:hover", @css.respo_style(opacity=1.0, color=Hsl(0, 70, 50))), +]) + +///| +let style_main_area : String = @respo_node.static_style([ + ( + "&", + @css.respo_style( + flex=1.0, + display=Flex, + flex_direction=Column, + padding=Px(24), + ).add("overflow-y", "auto"), + ), +]) + +///| +let style_new_query_container : String = @respo_node.static_style([ ( "&", @css.respo_style( - font_size=24, + max_width=Px(800), + width=Percent(100), + margin_left=Auto, + margin_right=Auto, + ), + ), +]) + +///| +let style_main_title : String = @respo_node.static_style([ + ( + "&", + @css.respo_style( + font_size=28, font_weight="bold", - margin_bottom=Px(16), + margin_bottom=Px(24), color=Hsl(220, 13, 18), ), ), ]) ///| -let style_input_row : String = @respo_node.static_style([ - ("&", @css.respo_style(display=Flex, gap=Px(8), margin_bottom=Px(16))), +let style_input_container : String = @respo_node.static_style([ + ("&", @css.respo_style(margin_bottom=Px(16))), ]) ///| -let style_input : String = @respo_node.static_style([ +let style_textarea : String = @respo_node.static_style([ ( "&", @css.respo_style( - flex=1.0, - padding=Px(12), + width=Percent(100), + padding=Px(16), font_size=16, + line_height=Em(1.6), border=@css.CssBorder::new(width=1.0, style=Solid, color=Hsl(220, 13, 80)), - border_radius=6.0, + border_radius=8.0, outline=@css.CssOutline::None, + resize=Vertical, + ).add("min-height", "150px"), + ), + ( + "&:focus", + @css.respo_style( + border=@css.CssBorder::new(width=2.0, style=Solid, color=Hsl(220, 90, 56)), ), ), ]) ///| -let style_button : String = @respo_node.static_style([ +let style_button_row : String = @respo_node.static_style([ + ("&", @css.respo_style(display=Flex, justify_content=FlexEnd)), +]) + +///| +let style_submit_button : String = @respo_node.static_style([ ( "&", @css.respo_style( - padding_left=Px(24), - padding_right=Px(24), + padding_left=Px(32), + padding_right=Px(32), padding_top=Px(12), padding_bottom=Px(12), font_size=16, @@ -214,31 +478,59 @@ let style_button : String = @respo_node.static_style([ cursor=Pointer, ), ), + ("&:hover", @css.respo_style(background_color=Hsl(220, 90, 50))), ]) ///| -let style_error : String = @respo_node.static_style([ +let style_query_display : String = @respo_node.static_style([ ( "&", @css.respo_style( - padding=Px(12), - margin_bottom=Px(16), - background_color=Hsl(0, 80, 95), - color=Hsl(0, 70, 40), - border_radius=6.0, - border=@css.CssBorder::new(width=1.0, style=Solid, color=Hsl(0, 70, 80)), + margin_bottom=Px(20), + padding=Px(16), + background_color=Hsl(220, 14, 97), + border_radius=8.0, ), ), ]) +///| +let style_query_label : String = @respo_node.static_style([ + ( + "&", + @css.respo_style( + font_size=12, + font_weight="600", + color=Hsl(220, 13, 50), + margin_bottom=Px(8), + ).add("text-transform", "uppercase"), + ), +]) + +///| +let style_query_text : String = @respo_node.static_style([ + ( + "&", + @css.respo_style( + font_size=16, + line_height=Em(1.5), + color=Hsl(220, 13, 20), + white_space="pre-wrap", + ), + ), +]) + +///| +let style_result_container : String = @respo_node.static_style([ + ("&", @css.respo_style(flex=1.0)), +]) + ///| let style_result : String = @respo_node.static_style([ ( "&", @css.respo_style( padding=Px(16), - background_color=Hsl(220, 14, 96), - border_radius=8.0, white_space="pre-wrap", line_height=Em(1.6), font_size=15, @@ -250,7 +542,26 @@ let style_result : String = @respo_node.static_style([ let style_loading : String = @respo_node.static_style([ ( "&", - @css.respo_style(padding=Px(16), color=Hsl(220, 13, 50), font_style=Italic), + @css.respo_style( + padding=Px(24), + color=Hsl(220, 13, 50), + font_style=Italic, + font_size=16, + ), + ), +]) + +///| +let style_error : String = @respo_node.static_style([ + ( + "&", + @css.respo_style( + padding=Px(16), + background_color=Hsl(0, 80, 95), + color=Hsl(0, 70, 40), + border_radius=8.0, + border=@css.CssBorder::new(width=1.0, style=Solid, color=Hsl(0, 70, 80)), + ), ), ]) @@ -259,7 +570,7 @@ let style_copy_button : String = @respo_node.static_style([ ( "&", @css.respo_style( - margin_top=Px(12), + margin_top=Px(16), padding_left=Px(16), padding_right=Px(16), padding_top=Px(8), @@ -272,4 +583,5 @@ let style_copy_button : String = @respo_node.static_style([ cursor=Pointer, ), ), + ("&:hover", @css.respo_style(background_color=Hsl(220, 10, 85))), ]) diff --git a/src/store.mbt b/src/store.mbt index f4dd1e4..3baf7c4 100644 --- a/src/store.mbt +++ b/src/store.mbt @@ -1,9 +1,19 @@ +///| +struct HistoryItem { + id : Int + query : String + result : String + error : String + loading : Bool +} derive(Default, Eq, ToJson, @json.FromJson) + ///| struct Store { counted : Int states : @respo.RespoStatesTree - query_result : String - query_error : String + history : Array[HistoryItem] + current_id : Int? // None means new query mode + next_id : Int } derive(ToJson, @json.FromJson, Eq) ///| @@ -11,8 +21,9 @@ impl Default for Store with default() -> Store { { counted: 0, states: @respo.RespoStatesTree::default(), - query_result: "", - query_error: "", + history: [], + current_id: None, + next_id: 1, } } @@ -27,9 +38,12 @@ struct Task { ///| enum ActionOp { StatesChange(@respo.RespoUpdateState) - StartQuery(String) - QuerySuccess(String) - QueryError(String) + NewQuery // Create new query + SelectQuery(Int) // Select history item by id + DeleteQuery(Int) // Delete history item by id + StartQuery(String) // Start query with prompt + QuerySuccess(Int, String) // Query success with id and result + QueryError(Int, String) // Query error with id and error message } ///| @@ -42,11 +56,12 @@ impl Show for ActionOp with output(self, logger) -> Unit { let s = match self { StatesChange(state) => "StatesChange(\{state.cursor} \{state.backup.to_json()})" - // Intent(_intent) => "Intent(...)" - + NewQuery => "NewQuery" + SelectQuery(id) => "SelectQuery(\{id})" + DeleteQuery(id) => "DeleteQuery(\{id})" StartQuery(prompt) => "StartQuery(\{prompt})" - QuerySuccess(res) => "QuerySuccess(\{res})" - QueryError(err) => "QueryError(\{err})" + QuerySuccess(id, res) => "QuerySuccess(\{id}, \{res.length()} chars)" + QueryError(id, err) => "QueryError(\{id}, \{err})" } logger.write_string(s) } @@ -56,8 +71,49 @@ impl Show for ActionOp with output(self, logger) -> Unit { fn Store::update(self : Store, op : ActionOp) -> Store { match op { StatesChange(states) => { ..self, states: self.states.set_in(states) } - StartQuery(_) => { ..self, query_error: "", query_result: "" } - QuerySuccess(res) => { ..self, query_result: res } - QueryError(err) => { ..self, query_error: err } + NewQuery => { ..self, current_id: None } + SelectQuery(id) => { ..self, current_id: Some(id) } + DeleteQuery(id) => { + let new_history = self.history.filter(fn(item) { item.id != id }) + let new_current = if self.current_id == Some(id) { + None + } else { + self.current_id + } + { ..self, history: new_history, current_id: new_current } + } + StartQuery(prompt) => { + let id = self.next_id + let item : HistoryItem = { + id, + query: prompt, + result: "", + error: "", + loading: true, + } + let new_history = self.history.copy() + new_history.push(item) + { ..self, history: new_history, current_id: Some(id), next_id: id + 1 } + } + QuerySuccess(id, res) => { + let new_history = self.history.map(fn(item) { + if item.id == id { + { ..item, result: res, loading: false } + } else { + item + } + }) + { ..self, history: new_history } + } + QueryError(id, err) => { + let new_history = self.history.map(fn(item) { + if item.id == id { + { ..item, error: err, loading: false } + } else { + item + } + }) + { ..self, history: new_history } + } } } From 915b92cd42f4ef7e00bebf60d1603299f9215b15 Mon Sep 17 00:00:00 2001 From: tiye Date: Sun, 21 Dec 2025 00:55:10 +0800 Subject: [PATCH 8/8] get a sidebar for more search detail --- src/main.mbt | 177 +++++++++++- src/query_comp.mbt | 675 +++++++++++++++++++++++++++++++++++++++++++-- src/requests.mbt | 35 ++- src/store.mbt | 65 +++++ 4 files changed, 921 insertions(+), 31 deletions(-) diff --git a/src/main.mbt b/src/main.mbt index d8c08ec..a4af9cb 100644 --- a/src/main.mbt +++ b/src/main.mbt @@ -1,6 +1,127 @@ ///| let app_store_key : String = "moonverse-portal" +///| +/// Parse JSON string array like ["a", "b"] into Array[String] +fn parse_string_array(json_str : String) -> Array[String] { + if json_str.is_empty() || json_str == "[]" { + return [] + } + let parsed = try? @json.parse(json_str) + match parsed { + Ok(Array(items)) => { + let result : Array[String] = [] + for item in items { + match item { + String(s) => result.push(s) + _ => () + } + } + result + } + _ => [] + } +} + +///| +/// Parse code search details from JSON +fn parse_code_details(json_str : String) -> Array[CodeFileDetail] { + if json_str.is_empty() || json_str == "[]" { + return [] + } + let parsed = try? @json.parse(json_str) + match parsed { + Ok(Array(items)) => { + let result : Array[CodeFileDetail] = [] + for item in items { + match item { + Object(obj) => { + let path = match obj.get("path") { + Some(String(s)) => s + _ => "" + } + let match_count = match obj.get("matchCount") { + Some(Number(n, ..)) => n.to_int() + _ => 0 + } + let preview = match obj.get("preview") { + Some(String(s)) => s + _ => "" + } + result.push({ path, match_count, preview }) + } + _ => () + } + } + result + } + _ => [] + } +} + +///| +/// Parse docs search details from JSON +fn parse_docs_details(json_str : String) -> Array[DocFileDetail] { + if json_str.is_empty() || json_str == "[]" { + return [] + } + let parsed = try? @json.parse(json_str) + match parsed { + Ok(Array(items)) => { + let result : Array[DocFileDetail] = [] + for item in items { + match item { + Object(obj) => { + let name = match obj.get("name") { + Some(String(s)) => s + _ => "" + } + let file_type = match obj.get("type") { + Some(String(s)) => s + _ => "" + } + let preview = match obj.get("preview") { + Some(String(s)) => s + _ => "" + } + result.push({ name, file_type, preview }) + } + _ => () + } + } + result + } + _ => [] + } +} + +///| +/// Parse query stats from JSON +fn parse_stats(json_str : String) -> QueryStats { + if json_str.is_empty() || json_str == "{}" { + return QueryStats::default() + } + let parsed = try? @json.parse(json_str) + match parsed { + Ok(Object(obj)) => { + let llm_calls = match obj.get("llmCalls") { + Some(Number(n, ..)) => n.to_int() + _ => 0 + } + let search_calls = match obj.get("searchCalls") { + Some(Number(n, ..)) => n.to_int() + _ => 0 + } + let tool_calls = match obj.get("toolCalls") { + Some(Number(n, ..)) => n.to_int() + _ => 0 + } + { llm_calls, search_calls, tool_calls } + } + _ => QueryStats::default() + } +} + ///| fn view( store : Store, @@ -37,7 +158,61 @@ fn main { let id = app.store.val.next_id - 1 run_query_js( prompt, - fn(result) { + fn( + phase, + message, + code_count, + docs_count, + partial, + code_details_json, + docs_details_json, + stats_json, + ) { + let code_search_details = parse_code_details(code_details_json) + let docs_search_details = parse_docs_details(docs_details_json) + let stats = parse_stats(stats_json) + let progress : QueryProgress = { + phase, + message, + code_results_count: code_count, + docs_results_count: docs_count, + partial_content: partial, + code_files: code_search_details.map(fn(d) { d.path }), + docs_files: docs_search_details.map(fn(d) { d.name }), + code_search_details, + docs_search_details, + stats, + } + app.store.val = app.store.val.update(QueryProgress(id, progress)) + @respo.mark_need_rerender() + }, + fn( + result, + code_count, + docs_count, + code_details_json, + docs_details_json, + stats_json, + ) { + let code_search_details = parse_code_details(code_details_json) + let docs_search_details = parse_docs_details(docs_details_json) + let stats = parse_stats(stats_json) + // First update progress to preserve counts and files + let final_progress : QueryProgress = { + phase: "complete", + message: "查询完成", + code_results_count: code_count, + docs_results_count: docs_count, + partial_content: "", + code_files: code_search_details.map(fn(d) { d.path }), + docs_files: docs_search_details.map(fn(d) { d.name }), + code_search_details, + docs_search_details, + stats, + } + app.store.val = app.store.val.update( + QueryProgress(id, final_progress), + ) app.store.val = app.store.val.update(QuerySuccess(id, result)) @respo.mark_need_rerender() }, diff --git a/src/query_comp.mbt b/src/query_comp.mbt index 25a7556..a0aaa56 100644 --- a/src/query_comp.mbt +++ b/src/query_comp.mbt @@ -179,45 +179,282 @@ fn comp_new_query( ///| fn comp_query_detail(item : HistoryItem) -> @respo_node.RespoNode[ActionOp] { - @respo_node.div(class_name=style_main_area, [ - // 显示查询内容(只读) - @respo_node.div(class_name=style_query_display, [ - @respo_node.div(class_name=style_query_label, [ - @respo_node.text_node("Query:"), + @respo_node.div(class_name=style_detail_container, [ + // 左侧主内容区 + @respo_node.div(class_name=style_detail_main, [ + // 显示查询内容(只读) + @respo_node.div(class_name=style_query_display, [ + @respo_node.div(class_name=style_query_label, [ + @respo_node.text_node("Query:"), + ]), + @respo_node.div(class_name=style_query_text, [ + @respo_node.text_node(item.query), + ]), + ]), + // 结果或加载状态 + if item.loading { + @respo_node.div(class_name=style_loading, [ + @respo_node.div([@respo_node.text_node("Thinking...")]), + // 显示进度信息 + if not(item.progress.message.is_empty()) { + @respo_node.div(class_name=style_progress_info, [ + @respo_node.div(class_name=style_progress_phase, [ + @respo_node.text_node("Phase: \{item.progress.phase}"), + ]), + @respo_node.div(class_name=style_progress_message, [ + @respo_node.text_node(item.progress.message), + ]), + @respo_node.div(class_name=style_progress_counts, [ + @respo_node.text_node( + "Code: \{item.progress.code_results_count} | Docs: \{item.progress.docs_results_count}", + ), + ]), + if not(item.progress.partial_content.is_empty()) { + @respo_node.div(class_name=style_progress_partial, [ + @respo_node.text_node(item.progress.partial_content), + ]) + } else { + @respo_node.span([]) + }, + ]) + } else { + @respo_node.span([]) + }, + ]) + } else if not(item.error.is_empty()) { + @respo_node.div(class_name=style_error, [ + @respo_node.text_node(item.error), + ]) + } else if not(item.result.is_empty()) { + @respo_node.div(class_name=style_result_container, [ + @respo_node.div(class_name=style_result, [ + @markdown.comp_md_block(item.result), + ]), + @respo_node.button( + inner_text="Copy Raw Markdown", + class_name=style_copy_button, + on_click=fn(_e, _dispatch) { + copy_to_clipboard(item.result) + println("Copied markdown to clipboard") + }, + ), + ]) + } else { + @respo_node.span([]) + }, + ]), + // 右侧搜索详情面板 + comp_search_details(item.progress), + ]) +} + +///| +fn comp_search_details( + progress : QueryProgress, +) -> @respo_node.RespoNode[ActionOp] { + @respo_node.div(class_name=style_search_details, [ + // 标题 + @respo_node.div(class_name=style_details_title, [ + @respo_node.text_node("搜索详情"), + ]), + // 搜索统计信息 + @respo_node.div(class_name=style_details_section, [ + @respo_node.div(class_name=style_details_label, [ + @respo_node.text_node("搜索统计"), + ]), + @respo_node.div(class_name=style_details_stat, [ + @respo_node.text_node("代码匹配: \{progress.code_results_count}"), ]), - @respo_node.div(class_name=style_query_text, [ - @respo_node.text_node(item.query), + @respo_node.div(class_name=style_details_stat, [ + @respo_node.text_node("文档文件: \{progress.docs_results_count}"), ]), ]), - // 结果或加载状态 - if item.loading { - @respo_node.div(class_name=style_loading, [ - @respo_node.text_node("Thinking..."), + // 查询统计(LLM/搜索调用次数) + if progress.stats.llm_calls > 0 || progress.stats.search_calls > 0 { + @respo_node.div(class_name=style_details_section, [ + @respo_node.div(class_name=style_details_label, [ + @respo_node.text_node("调用统计"), + ]), + @respo_node.div(class_name=style_stats_grid, [ + @respo_node.div(class_name=style_stat_item, [ + @respo_node.div(class_name=style_stat_value, [ + @respo_node.text_node("\{progress.stats.llm_calls}"), + ]), + @respo_node.div(class_name=style_stat_label, [ + @respo_node.text_node("LLM"), + ]), + ]), + @respo_node.div(class_name=style_stat_item, [ + @respo_node.div(class_name=style_stat_value, [ + @respo_node.text_node("\{progress.stats.search_calls}"), + ]), + @respo_node.div(class_name=style_stat_label, [ + @respo_node.text_node("搜索"), + ]), + ]), + @respo_node.div(class_name=style_stat_item, [ + @respo_node.div(class_name=style_stat_value, [ + @respo_node.text_node("\{progress.stats.tool_calls}"), + ]), + @respo_node.div(class_name=style_stat_label, [ + @respo_node.text_node("工具"), + ]), + ]), + ]), ]) - } else if not(item.error.is_empty()) { - @respo_node.div(class_name=style_error, [ - @respo_node.text_node(item.error), + } else { + @respo_node.span([]) + }, + // 代码搜索详情(带预览) + if not(progress.code_search_details.is_empty()) { + @respo_node.div(class_name=style_details_section, [ + @respo_node.div(class_name=style_details_label, [ + @respo_node.text_node( + "匹配的代码文件 (\{progress.code_search_details.length()})", + ), + ]), + @respo_node.div( + class_name=style_file_list, + progress.code_search_details + .iter() + .map(fn(detail) { comp_code_detail_card(detail) }) + .collect(), + ), ]) - } else if not(item.result.is_empty()) { - @respo_node.div(class_name=style_result_container, [ - @respo_node.div(class_name=style_result, [ - @markdown.comp_md_block(item.result), + } else if not(progress.code_files.is_empty()) { + // Fallback to simple file list if no details + @respo_node.div(class_name=style_details_section, [ + @respo_node.div(class_name=style_details_label, [ + @respo_node.text_node( + "匹配的代码文件 (\{progress.code_files.length()})", + ), ]), - @respo_node.button( - inner_text="Copy Raw Markdown", - class_name=style_copy_button, - on_click=fn(_e, _dispatch) { - copy_to_clipboard(item.result) - println("Copied markdown to clipboard") - }, + @respo_node.div( + class_name=style_file_list, + progress.code_files + .iter() + .map(fn(f) { + @respo_node.div(class_name=style_file_item, [ + @respo_node.text_node(f), + ]) + }) + .collect(), ), ]) + } else { + @respo_node.div(class_name=style_details_section, [ + @respo_node.div(class_name=style_details_label, [ + @respo_node.text_node("代码文件"), + ]), + @respo_node.div(class_name=style_details_empty, [ + @respo_node.text_node("无匹配"), + ]), + ]) + }, + // 文档详情 + if not(progress.docs_search_details.is_empty()) { + @respo_node.div(class_name=style_details_section, [ + @respo_node.div(class_name=style_details_label, [ + @respo_node.text_node( + "使用的文档 (\{progress.docs_search_details.length()})", + ), + ]), + @respo_node.div( + class_name=style_file_list, + progress.docs_search_details + .iter() + .map(fn(detail) { comp_doc_detail_card(detail) }) + .collect(), + ), + ]) + } else if not(progress.docs_files.is_empty()) { + // Fallback to simple file list + @respo_node.div(class_name=style_details_section, [ + @respo_node.div(class_name=style_details_label, [ + @respo_node.text_node( + "使用的文档 (\{progress.docs_files.length()})", + ), + ]), + @respo_node.div( + class_name=style_file_list, + progress.docs_files + .iter() + .map(fn(f) { + @respo_node.div(class_name=style_file_item_doc, [ + @respo_node.text_node(f), + ]) + }) + .collect(), + ), + ]) + } else { + @respo_node.div(class_name=style_details_section, [ + @respo_node.div(class_name=style_details_label, [ + @respo_node.text_node("文档文件"), + ]), + @respo_node.div(class_name=style_details_empty, [ + @respo_node.text_node("无"), + ]), + ]) + }, + ]) +} + +///| +fn comp_code_detail_card( + detail : CodeFileDetail, +) -> @respo_node.RespoNode[ActionOp] { + @respo_node.div(class_name=style_code_card, [ + @respo_node.div(class_name=style_code_card_header, [ + @respo_node.div(class_name=style_code_card_path, [ + @respo_node.text_node(detail.path), + ]), + @respo_node.div(class_name=style_code_card_count, [ + @respo_node.text_node("\{detail.match_count} 行"), + ]), + ]), + if not(detail.preview.is_empty()) { + @respo_node.div(class_name=style_code_card_preview, [ + @respo_node.text_node(truncate_preview(detail.preview, 150)), + ]) + } else { + @respo_node.span([]) + }, + ]) +} + +///| +fn comp_doc_detail_card( + detail : DocFileDetail, +) -> @respo_node.RespoNode[ActionOp] { + @respo_node.div(class_name=style_doc_card, [ + @respo_node.div(class_name=style_doc_card_header, [ + @respo_node.div(class_name=style_doc_card_name, [ + @respo_node.text_node(detail.name), + ]), + @respo_node.div(class_name=style_doc_card_type, [ + @respo_node.text_node(detail.file_type), + ]), + ]), + if not(detail.preview.is_empty()) { + @respo_node.div(class_name=style_doc_card_preview, [ + @respo_node.text_node(truncate_preview(detail.preview, 150)), + ]) } else { @respo_node.span([]) }, ]) } +///| +fn truncate_preview(s : String, max_len : Int) -> String { + if s.length() <= max_len { + s + } else { + (try! s[:max_len]).to_string() + "..." + } +} + // ============= Styles ============= ///| @@ -585,3 +822,391 @@ let style_copy_button : String = @respo_node.static_style([ ), ("&:hover", @css.respo_style(background_color=Hsl(220, 10, 85))), ]) + +///| +let style_progress_info : String = @respo_node.static_style([ + ( + "&", + @css.respo_style( + margin_top=Px(16), + padding=Px(16), + background_color=Hsl(220, 20, 97), + border_radius=8.0, + font_size=14, + color=Hsl(220, 13, 40), + ).add("border", "1px solid hsl(220, 13%, 88%)"), + ), +]) + +///| +let style_progress_phase : String = @respo_node.static_style([ + ( + "&", + @css.respo_style( + font_weight="600", + color=Hsl(220, 50, 50), + margin_bottom=Px(8), + ), + ), +]) + +///| +let style_progress_message : String = @respo_node.static_style([ + ("&", @css.respo_style(margin_bottom=Px(8), line_height=Em(1.4))), +]) + +///| +let style_progress_counts : String = @respo_node.static_style([ + ( + "&", + @css.respo_style(font_size=13, color=Hsl(220, 13, 50), margin_bottom=Px(8)), + ), +]) + +///| +let style_progress_partial : String = @respo_node.static_style([ + ( + "&", + @css.respo_style( + font_size=13, + color=Hsl(220, 13, 60), + font_style=Italic, + margin_top=Px(8), + padding_top=Px(8), + ).add("border-top", "1px dashed hsl(220, 13%, 80%)"), + ), +]) + +///| +let style_result_stats : String = @respo_node.static_style([ + ( + "&", + @css.respo_style( + padding_left=Px(16), + padding_right=Px(16), + padding_top=Px(12), + padding_bottom=Px(12), + font_size=13, + color=Hsl(220, 50, 45), + background_color=Hsl(220, 30, 96), + ).add("border-bottom", "1px solid hsl(220, 13%, 88%)"), + ), +]) + +///| +let style_detail_container : String = @respo_node.static_style([ + ( + "&", + @css.respo_style(display=Flex, flex=1.0, height=Vh(100)).add( + "overflow", "hidden", + ), + ), +]) + +///| +let style_detail_main : String = @respo_node.static_style([ + ( + "&", + @css.respo_style( + flex=1.0, + display=Flex, + flex_direction=Column, + padding=Px(24), + ).add("overflow-y", "auto"), + ), +]) + +///| +let style_search_details : String = @respo_node.static_style([ + ( + "&", + @css.respo_style( + width=Px(420), + min_width=Px(420), + background_color=Hsl(220, 14, 98), + padding=Px(20), + ) + .add("border-left", "1px solid hsl(220, 13%, 88%)") + .add("overflow-y", "auto"), + ), +]) + +///| +let style_details_title : String = @respo_node.static_style([ + ( + "&", + @css.respo_style( + font_size=14, + font_weight="600", + color=Hsl(220, 13, 30), + margin_bottom=Px(16), + padding_bottom=Px(12), + ).add("border-bottom", "1px solid hsl(220, 13%, 88%)"), + ), +]) + +///| +let style_details_section : String = @respo_node.static_style([ + ("&", @css.respo_style(margin_bottom=Px(20))), +]) + +///| +let style_details_label : String = @respo_node.static_style([ + ( + "&", + @css.respo_style( + font_size=12, + font_weight="600", + color=Hsl(220, 13, 50), + margin_bottom=Px(8), + ).add("text-transform", "uppercase"), + ), +]) + +///| +let style_details_stat : String = @respo_node.static_style([ + ( + "&", + @css.respo_style(font_size=13, color=Hsl(220, 13, 40), margin_bottom=Px(4)), + ), +]) + +///| +let style_details_empty : String = @respo_node.static_style([ + ( + "&", + @css.respo_style(font_size=12, color=Hsl(220, 13, 60), font_style=Italic), + ), +]) + +///| +let style_file_list : String = @respo_node.static_style([ + ( + "&", + @css.respo_style(font_size=12, max_height=Px(400)).add("overflow-y", "auto"), + ), +]) + +///| +let style_file_item : String = @respo_node.static_style([ + ( + "&", + @css.respo_style( + padding_top=Px(4), + padding_bottom=Px(4), + padding_left=Px(8), + padding_right=Px(8), + margin_bottom=Px(4), + background_color=Hsl(200, 50, 95), + color=Hsl(200, 50, 30), + border_radius=4.0, + font_family="ui-monospace, 'SF Mono', Menlo, monospace", + ).add("word-break", "break-all"), + ), +]) + +///| +let style_file_item_doc : String = @respo_node.static_style([ + ( + "&", + @css.respo_style( + padding_top=Px(4), + padding_bottom=Px(4), + padding_left=Px(8), + padding_right=Px(8), + margin_bottom=Px(4), + background_color=Hsl(140, 40, 95), + color=Hsl(140, 40, 30), + border_radius=4.0, + font_family="ui-monospace, 'SF Mono', Menlo, monospace", + ).add("word-break", "break-all"), + ), +]) + +///| +let style_code_card : String = @respo_node.static_style([ + ( + "&", + @css.respo_style( + margin_bottom=Px(8), + padding=Px(10), + background_color=Hsl(200, 30, 97), + border_radius=6.0, + ).add("border", "1px solid hsl(200, 30%, 88%)"), + ), +]) + +///| +let style_code_card_header : String = @respo_node.static_style([ + ( + "&", + @css.respo_style( + display=Flex, + justify_content=SpaceBetween, + align_items=Center, + margin_bottom=Px(6), + ), + ), +]) + +///| +let style_code_card_path : String = @respo_node.static_style([ + ( + "&", + @css.respo_style( + font_size=12, + font_weight="600", + color=Hsl(200, 50, 35), + font_family="ui-monospace, 'SF Mono', Menlo, monospace", + ).add("word-break", "break-all"), + ), +]) + +///| +let style_code_card_count : String = @respo_node.static_style([ + ( + "&", + @css.respo_style( + font_size=11, + color=Hsl(200, 40, 50), + background_color=Hsl(200, 50, 92), + padding_left=Px(6), + padding_right=Px(6), + padding_top=Px(2), + padding_bottom=Px(2), + border_radius=4.0, + white_space="nowrap", + ), + ), +]) + +///| +let style_code_card_preview : String = @respo_node.static_style([ + ( + "&", + @css.respo_style( + font_size=11, + line_height=Em(1.4), + color=Hsl(220, 10, 45), + font_family="ui-monospace, 'SF Mono', Menlo, monospace", + white_space="pre-wrap", + max_height=Px(80), + ) + .add("overflow-y", "auto") + .add("word-break", "break-all"), + ), +]) + +///| +let style_doc_card : String = @respo_node.static_style([ + ( + "&", + @css.respo_style( + margin_bottom=Px(6), + padding=Px(8), + background_color=Hsl(140, 30, 97), + border_radius=6.0, + display=Flex, + flex_direction=Column, + ).add("border", "1px solid hsl(140, 30%, 88%)"), + ), +]) + +///| +let style_doc_card_header : String = @respo_node.static_style([ + ( + "&", + @css.respo_style( + display=Flex, + justify_content=SpaceBetween, + align_items=Center, + margin_bottom=Px(4), + ), + ), +]) + +///| +let style_doc_card_name : String = @respo_node.static_style([ + ( + "&", + @css.respo_style( + font_size=12, + font_weight="600", + color=Hsl(140, 40, 30), + font_family="ui-monospace, 'SF Mono', Menlo, monospace", + ).add("word-break", "break-all"), + ), +]) + +///| +let style_doc_card_type : String = @respo_node.static_style([ + ( + "&", + @css.respo_style( + font_size=10, + color=Hsl(140, 30, 50), + background_color=Hsl(140, 40, 92), + padding_left=Px(6), + padding_right=Px(6), + padding_top=Px(2), + padding_bottom=Px(2), + border_radius=4.0, + white_space="nowrap", + ), + ), +]) + +///| +let style_doc_card_preview : String = @respo_node.static_style([ + ( + "&", + @css.respo_style( + font_size=11, + color=Hsl(140, 30, 45), + font_family="ui-monospace, 'SF Mono', Menlo, monospace", + background_color=Hsl(140, 20, 94), + padding=Px(6), + border_radius=4.0, + margin_top=Px(4), + ) + .add("word-break", "break-all") + .add("white-space", "pre-wrap"), + ), +]) + +///| +let style_stats_grid : String = @respo_node.static_style([ + ( + "&", + @css.respo_style(display=Flex).add("gap", "8px").add("flex-wrap", "wrap"), + ), +]) + +///| +let style_stat_item : String = @respo_node.static_style([ + ( + "&", + @css.respo_style( + display=Flex, + flex_direction=Column, + align_items=Center, + padding=Px(8), + background_color=Hsl(210, 30, 96), + border_radius=6.0, + min_width=Px(50), + ).add("border", "1px solid hsl(210, 30%, 88%)"), + ), +]) + +///| +let style_stat_value : String = @respo_node.static_style([ + ( + "&", + @css.respo_style(font_size=16, font_weight="700", color=Hsl(210, 50, 40)), + ), +]) + +///| +let style_stat_label : String = @respo_node.static_style([ + ("&", @css.respo_style(font_size=10, color=Hsl(210, 30, 50))), +]) diff --git a/src/requests.mbt b/src/requests.mbt index c9d83d9..50a8c56 100644 --- a/src/requests.mbt +++ b/src/requests.mbt @@ -64,12 +64,15 @@ extern "js" fn schedule_async(promise : @js_async.Promise[Unit]) -> Unit = ///| /// Run query using pure JS async - bypassing MoonBit async issues +/// on_progress receives: (phase, message, code_count, docs_count, partial_content, code_details_json, docs_details_json, stats_json) +/// on_success receives: (result, code_count, docs_count, code_details_json, docs_details_json, stats_json) extern "js" fn run_query_js( prompt : String, - on_success : (String) -> Unit, + on_progress : (String, String, Int, Int, String, String, String, String) -> Unit, + on_success : (String, Int, Int, String, String, String) -> Unit, on_error : (String) -> Unit, ) -> Unit = - #| async (prompt, onSuccess, onError) => { + #| async (prompt, onProgress, onSuccess, onError) => { #| try { #| const params = new URLSearchParams(window.location.search); #| const isDev = params.get("mode") === "dev"; @@ -90,6 +93,11 @@ extern "js" fn run_query_js( #| const json = await resp.json(); #| const id = json.id; #| console.log("run_query_js: got task id:", id); + #| let lastCodeCount = 0; + #| let lastDocsCount = 0; + #| let lastCodeDetails = []; + #| let lastDocsDetails = []; + #| let lastStats = { llmCalls: 0, searchCalls: 0, toolCalls: 0 }; #| while (true) { #| console.log("run_query_js: polling /query/" + id); #| const pollResp = await fetch(baseUrl + "/query/" + id); @@ -98,15 +106,32 @@ extern "js" fn run_query_js( #| return; #| } #| const pollJson = await pollResp.json(); - #| console.log("run_query_js: poll status:", pollJson.status); + #| console.log("run_query_js: poll status:", pollJson.status, "phase:", pollJson.phase); + #| // Track counts and details + #| if (pollJson.codeResultsCount !== undefined) lastCodeCount = pollJson.codeResultsCount; + #| if (pollJson.docsResultsCount !== undefined) lastDocsCount = pollJson.docsResultsCount; + #| if (pollJson.codeSearchDetails) lastCodeDetails = pollJson.codeSearchDetails; + #| if (pollJson.docsSearchDetails) lastDocsDetails = pollJson.docsSearchDetails; + #| if (pollJson.queryStats) lastStats = pollJson.queryStats; #| if (pollJson.status === "done") { - #| onSuccess(pollJson.content || pollJson.result || ""); + #| const codeCount = pollJson.codeResultsCount ?? lastCodeCount; + #| const docsCount = pollJson.docsResultsCount ?? lastDocsCount; + #| const codeDetails = pollJson.codeSearchDetails || lastCodeDetails; + #| const docsDetails = pollJson.docsSearchDetails || lastDocsDetails; + #| const stats = pollJson.queryStats || lastStats; + #| onSuccess(pollJson.content || pollJson.result || "", codeCount, docsCount, JSON.stringify(codeDetails), JSON.stringify(docsDetails), JSON.stringify(stats)); #| return; #| } else if (pollJson.status === "error") { #| onError(pollJson.error || "Query failed"); #| return; #| } - #| await new Promise(r => setTimeout(r, 1000)); + #| // Report progress + #| const phase = pollJson.phase || "running"; + #| const message = pollJson.message || "处理中..."; + #| const partialContent = pollJson.partialContent || ""; + #| onProgress(phase, message, lastCodeCount, lastDocsCount, partialContent, JSON.stringify(lastCodeDetails), JSON.stringify(lastDocsDetails), JSON.stringify(lastStats)); + #| const waitTime = (pollJson.nextPollSec || 1) * 1000; + #| await new Promise(r => setTimeout(r, waitTime)); #| } #| } catch (e) { #| console.error("run_query_js error:", e); diff --git a/src/store.mbt b/src/store.mbt index 3baf7c4..ff71e47 100644 --- a/src/store.mbt +++ b/src/store.mbt @@ -1,3 +1,42 @@ +///| +/// 代码文件搜索详情 +struct CodeFileDetail { + path : String // 文件路径 + match_count : Int // 匹配行数 + preview : String // 内容预览 +} derive(Default, Eq, ToJson, @json.FromJson) + +///| +/// 文档文件详情 +struct DocFileDetail { + name : String // 文件名 + file_type : String // MIME 类型 + preview : String // 内容预览(可选) +} derive(Default, Eq, ToJson, @json.FromJson) + +///| +/// 查询统计信息 +struct QueryStats { + llm_calls : Int // LLM 调用次数 + search_calls : Int // 搜索调用次数 + tool_calls : Int // 工具调用次数 +} derive(Default, Eq, ToJson, @json.FromJson) + +///| +/// 查询进度信息 +struct QueryProgress { + phase : String // initializing, searching-code, searching-docs, analyzing, generating, complete + message : String // 当前进度消息 + code_results_count : Int // 找到的代码匹配数 + docs_results_count : Int // 使用的文档数 + partial_content : String // 部分结果或额外信息 + code_files : Array[String] // 匹配的代码文件列表 + docs_files : Array[String] // 使用的文档文件列表 + code_search_details : Array[CodeFileDetail] // 代码搜索详情 + docs_search_details : Array[DocFileDetail] // 文档详情 + stats : QueryStats // 统计信息 +} derive(Default, Eq, ToJson, @json.FromJson) + ///| struct HistoryItem { id : Int @@ -5,6 +44,7 @@ struct HistoryItem { result : String error : String loading : Bool + progress : QueryProgress // 进度信息 } derive(Default, Eq, ToJson, @json.FromJson) ///| @@ -42,6 +82,7 @@ enum ActionOp { SelectQuery(Int) // Select history item by id DeleteQuery(Int) // Delete history item by id StartQuery(String) // Start query with prompt + QueryProgress(Int, QueryProgress) // Update progress for a query QuerySuccess(Int, String) // Query success with id and result QueryError(Int, String) // Query error with id and error message } @@ -60,6 +101,8 @@ impl Show for ActionOp with output(self, logger) -> Unit { SelectQuery(id) => "SelectQuery(\{id})" DeleteQuery(id) => "DeleteQuery(\{id})" StartQuery(prompt) => "StartQuery(\{prompt})" + QueryProgress(id, progress) => + "QueryProgress(\{id}, \{progress.phase}: \{progress.message})" QuerySuccess(id, res) => "QuerySuccess(\{id}, \{res.length()} chars)" QueryError(id, err) => "QueryError(\{id}, \{err})" } @@ -90,11 +133,33 @@ fn Store::update(self : Store, op : ActionOp) -> Store { result: "", error: "", loading: true, + progress: { + phase: "initializing", + message: "正在初始化查询...", + code_results_count: 0, + docs_results_count: 0, + partial_content: "", + code_files: [], + docs_files: [], + code_search_details: [], + docs_search_details: [], + stats: QueryStats::default(), + }, } let new_history = self.history.copy() new_history.push(item) { ..self, history: new_history, current_id: Some(id), next_id: id + 1 } } + QueryProgress(id, progress) => { + let new_history = self.history.map(fn(item) { + if item.id == id { + { ..item, progress, } + } else { + item + } + }) + { ..self, history: new_history } + } QuerySuccess(id, res) => { let new_history = self.history.map(fn(item) { if item.id == id {