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/moon.mod.json b/moon.mod.json index 13f134b..031c6ef 100644 --- a/moon.mod.json +++ b/moon.mod.json @@ -2,9 +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.3" }, "readme": "README.md", "repository": "", diff --git a/src/main.mbt b/src/main.mbt new file mode 100644 index 0000000..a4af9cb --- /dev/null +++ b/src/main.mbt @@ -0,0 +1,235 @@ +///| +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, +) -> @respo_node.RespoNode[ActionOp] raise @respo_node.RespoCommonError { + if false { + raise @respo_node.RespoCommonError("guarded") + } + // @dom_ffi.log("Store to render: " + store.to_json().stringify(indent=2)) + comp_app(store.states.pick("app"), store) +} + +///| +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(store_0), + mount_target, + storage_key: app_store_key, + } + app.backup_model_beforeunload() + // @dom_ffi.log("store: " + app.store.val.to_json().stringify(indent=2)) + app.render_loop(fn() { view(app.store.val) }, fn(op) { + if not(op is StatesChange(_)) { + @dom_ffi.log("Action: \{op}") + } + app.store.val = app.store.val.update(op) + match op { + StartQuery(prompt) => { + // Get the id that was just created + let id = app.store.val.next_id - 1 + run_query_js( + prompt, + 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() + }, + fn(err) { + app.store.val = app.store.val.update(QueryError(id, err)) + @respo.mark_need_rerender() + }, + ) + } + _ => () + } + }) + let dev_mode = @dom_ffi.new_url_search_params(window.location().search()).get( + "mode", + ) + @dom_ffi.log("dev mode: \{dev_mode}") + } else { + @dom_ffi.log("No mount target found") + } +} 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/main.mbt b/src/main/main.mbt deleted file mode 100644 index 1d0a0ad..0000000 --- a/src/main/main.mbt +++ /dev/null @@ -1,48 +0,0 @@ -///| -using @respo_node {div, span, button} - -///| -using @css {respo_style} - -///| -let app_store_key : String = "mbt-workflow" - -///| -fn view( - store : Store, -) -> @respo_node.RespoNode[ActionOp] raise @respo_node.RespoCommonError { - if false { - 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_counter(store.states.pick("counter"), store.counted), - ]) -} - -///| -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 app : @respo.RespoApp[Store] = { - store: Ref::new(@respo.try_load_storage(app_store_key)), - mount_target, - storage_key: app_store_key, - } - app.backup_model_beforeunload() - // @dom_ffi.log("store: " + app.store.val.to_json().stringify(indent=2)) - app.render_loop(fn() { view(app.store.val) }, fn(op) { - if not(op is StatesChange(_)) { - @dom_ffi.log("Action: \{op}") - } - app.store.val = app.store.val.update(op) - }) - let dev_mode = @dom_ffi.new_url_search_params(window.location().search()).get( - "mode", - ) - @dom_ffi.log("dev mode: \{dev_mode}") - } else { - @dom_ffi.log("No mount target found") - } -} diff --git a/src/main/store.mbt b/src/main/store.mbt deleted file mode 100644 index 96f36ac..0000000 --- a/src/main/store.mbt +++ /dev/null @@ -1,55 +0,0 @@ -///| -struct Store { - counted : Int - states : @respo.RespoStatesTree -} derive(ToJson, @json.FromJson, Eq) - -///| -impl Default for Store with default() -> Store { - { counted: 0, states: @respo.RespoStatesTree::default() } -} - -///| -struct Task { - id : String - done : Bool - content : String - time : Double -} derive(Default, Eq, Hash, ToJson, @json.FromJson) - -///| -enum ActionOp { - StatesChange(@respo.RespoUpdateState) - Increment - Decrement - IncTwice -} - -///| -impl @respo_node.RespoAction for ActionOp with build_states_action(cursor, a, j) { - StatesChange({ cursor, data: a.map(@dom_ffi.js_obscure_to_v), backup: j }) -} - -///| -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(...)" - Increment => "Increment" - Decrement => "Decrement" - IncTwice => "IncTwice" - } - logger.write_string(s) -} - -///| -/// 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 } - _ => self - } -} diff --git a/src/main/moon.pkg.json b/src/moon.pkg.json similarity index 56% rename from src/main/moon.pkg.json rename to src/moon.pkg.json index 59ac124..52ff3c7 100644 --- a/src/main/moon.pkg.json +++ b/src/moon.pkg.json @@ -10,6 +10,8 @@ "alias": "respo_node" }, { "path": "tiye/dom-ffi", "alias": "dom_ffi" }, - { "path": "tiye/respo_css", "alias": "css" } + { "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 new file mode 100644 index 0000000..a0aaa56 --- /dev/null +++ b/src/query_comp.mbt @@ -0,0 +1,1212 @@ +///| +struct QueryState { + input_val : String +} 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_app( + states : @respo.RespoStatesTree, + store : Store, +) -> @respo_node.RespoNode[ActionOp] { + @respo_node.div(class_name=style_app_container, [ + // 左侧边栏 + comp_sidebar(store), + // 右侧主区域 + comp_main_area(states, store), + ]) +} + +///| +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)) + }), + ], + ) +} + +///| +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 { + _ => () + } + } + }, + ), + ]), + ]), + ]) +} + +///| +fn comp_query_detail(item : HistoryItem) -> @respo_node.RespoNode[ActionOp] { + @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_details_stat, [ + @respo_node.text_node("文档文件: \{progress.docs_results_count}"), + ]), + ]), + // 查询统计(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 { + @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(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.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 ============= + +///| +let style_app_container : String = @respo_node.static_style([ + ( + "&", + @css.respo_style( + display=Flex, + height=Vh(100), + font_family="system-ui, -apple-system, sans-serif", + ), + ), +]) + +///| +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( + 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(24), + color=Hsl(220, 13, 18), + ), + ), +]) + +///| +let style_input_container : String = @respo_node.static_style([ + ("&", @css.respo_style(margin_bottom=Px(16))), +]) + +///| +let style_textarea : String = @respo_node.static_style([ + ( + "&", + @css.respo_style( + 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=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_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(32), + padding_right=Px(32), + 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, + ), + ), + ("&:hover", @css.respo_style(background_color=Hsl(220, 90, 50))), +]) + +///| +let style_query_display : String = @respo_node.static_style([ + ( + "&", + @css.respo_style( + 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), + 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(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)), + ), + ), +]) + +///| +let style_copy_button : String = @respo_node.static_style([ + ( + "&", + @css.respo_style( + margin_top=Px(16), + 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, + ), + ), + ("&: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 new file mode 100644 index 0000000..50a8c56 --- /dev/null +++ b/src/requests.mbt @@ -0,0 +1,187 @@ +// 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)) + +///| +/// 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 +/// 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_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, onProgress, 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(baseUrl + "/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); + #| 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); + #| if (!pollResp.ok) { + #| onError("Failed to poll: " + pollResp.status); + #| return; + #| } + #| const pollJson = await pollResp.json(); + #| 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") { + #| 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; + #| } + #| // 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); + #| onError(e.toString()); + #| } + #| } + +// 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("http://localhost:8080/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( + "http://localhost:8080/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/store.mbt b/src/store.mbt new file mode 100644 index 0000000..ff71e47 --- /dev/null +++ b/src/store.mbt @@ -0,0 +1,184 @@ +///| +/// 代码文件搜索详情 +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 + query : String + result : String + error : String + loading : Bool + progress : QueryProgress // 进度信息 +} derive(Default, Eq, ToJson, @json.FromJson) + +///| +struct Store { + counted : Int + states : @respo.RespoStatesTree + history : Array[HistoryItem] + current_id : Int? // None means new query mode + next_id : Int +} derive(ToJson, @json.FromJson, Eq) + +///| +impl Default for Store with default() -> Store { + { + counted: 0, + states: @respo.RespoStatesTree::default(), + history: [], + current_id: None, + next_id: 1, + } +} + +///| +struct Task { + id : String + done : Bool + content : String + time : Double +} derive(Default, Eq, Hash, ToJson, @json.FromJson) + +///| +enum ActionOp { + StatesChange(@respo.RespoUpdateState) + NewQuery // Create new query + 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 +} + +///| +impl @respo_node.RespoAction for ActionOp with build_states_action(cursor, a, j) { + StatesChange({ cursor, data: a.map(@dom_ffi.js_obscure_to_v), backup: j }) +} + +///| +impl Show for ActionOp with output(self, logger) -> Unit { + let s = match self { + StatesChange(state) => + "StatesChange(\{state.cursor} \{state.backup.to_json()})" + NewQuery => "NewQuery" + 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})" + } + logger.write_string(s) +} + +///| +/// update store immutably +fn Store::update(self : Store, op : ActionOp) -> Store { + match op { + StatesChange(states) => { ..self, states: self.states.set_in(states) } + 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, + 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 { + { ..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 } + } + } +}