1use std::{path::Path, rc::Rc};
13
14use gpui_kit::{
15 AnyElement, App, ElementId, Entity, FontWeight, IntoElement, ParentElement as _, SharedString,
16 Styled as _, Window,
17 component::{
18 ActiveTheme as _, Sizable as _,
19 button::{Button, ButtonVariants as _},
20 h_flex,
21 setting::{
22 AnySettingField, NumberFieldOptions, SettingField, SettingGroup, SettingItem,
23 SettingPage, Settings,
24 },
25 },
26 div,
27 prelude::FluentBuilder as _,
28};
29
30use super::{
31 ChatProvider, MemoryChoice, PdfBackend, RagChanges, RagController, RagField, RagSection,
32 RagSettings,
33};
34use crate::{
35 SettingRow, dirty_color, dirty_header, dropdown_control, number_control, path_control,
36 path_setting, switch_control, text_control, token_control,
37};
38
39pub type Changes = Rc<dyn Fn(&App) -> RagChanges>;
41
42pub fn no_changes() -> Changes {
44 Rc::new(|_| RagChanges::default())
45}
46
47pub type OpenPrompt = Rc<dyn Fn(Option<&Path>, &mut Window, &mut App)>;
52
53pub type ExportPrompt = Rc<dyn Fn(&mut Window, &mut App) -> Result<String, String>>;
57
58#[derive(Clone)]
63pub struct PromptActions {
64 pub built_in_name: &'static str,
66 pub open: OpenPrompt,
68 pub export: ExportPrompt,
70}
71
72pub fn rag_settings_page(
78 settings: Entity<RagSettings>,
79 controller: Entity<RagController>,
80 cx: &App,
81) -> SettingPage {
82 let changes = no_changes();
83 let now = RagChanges::default();
84 let mut page = SettingPage::new("RAG")
85 .description(
86 "Ragrig workspace, models, retrieval, and memory. Press Apply to rebuild the agent.",
87 )
88 .default_open(true)
89 .group(workspace_group(&settings))
90 .group(chat_group(&settings, &changes, &now, None))
91 .group(retrieval_group(&settings, &changes, &now))
92 .group(parsing_group(&settings, &changes, &now))
93 .group(memory_group(&settings, &changes, &now))
94 .group(rag_references_group(&settings, &changes));
95 page = page
96 .group(profiles_group(&settings, cx))
97 .group(apply_group(&settings, &controller));
98 page
99}
100
101pub fn rag_model_page(
108 settings: Entity<RagSettings>,
109 changes: Changes,
110 prompt: Option<PromptActions>,
111 cx: &App,
112) -> SettingPage {
113 let now = changes(cx);
114 let page_dirty = [
115 RagSection::Chat,
116 RagSection::Retrieval,
117 RagSection::Parsing,
118 RagSection::Memory,
119 ]
120 .iter()
121 .any(|section| now.section(*section));
122 let page = SettingPage::new(group_title("RAG", page_dirty))
123 .description(
124 "Models, retrieval, parsing, and memory. Press Apply on Overview to rebuild the agent.",
125 )
126 .default_open(true);
127 let page = if page_dirty {
128 page.header_style(&dirty_header(cx))
129 } else {
130 page
131 };
132 page.group(chat_group(&settings, &changes, &now, prompt.as_ref()))
133 .group(retrieval_group(&settings, &changes, &now))
134 .group(parsing_group(&settings, &changes, &now))
135 .group(memory_group(&settings, &changes, &now))
136}
137
138fn workspace_group(settings: &Entity<RagSettings>) -> SettingGroup {
140 let settings_for_workspace = settings.clone();
141 let workspace = path_setting(
142 "rag-workspace",
143 "Choose the Ragrig workspace",
144 true,
145 false,
146 {
147 let settings = settings.clone();
148 move |cx| settings.read(cx).workspace.clone()
149 },
150 move |value, cx| {
151 settings_for_workspace.update(cx, |settings, cx| {
152 settings.workspace = value;
153 cx.notify();
154 });
155 },
156 );
157 let settings_for_bibliography = settings.clone();
158 let bibliography = path_setting(
159 "rag-bibliography",
160 "Choose the bibliography file",
161 false,
162 true,
163 {
164 let settings = settings.clone();
165 move |cx| settings.read(cx).bibliography.clone()
166 },
167 move |value, cx| {
168 settings_for_bibliography.update(cx, |settings, cx| {
169 settings.bibliography = value;
170 cx.notify();
171 });
172 },
173 );
174
175 SettingGroup::new()
176 .title("Workspace")
177 .item(
178 SettingItem::new("Workspace", workspace)
179 .description("Store, sessions, and profiles live under this folder (.ragrig)."),
180 )
181 .item(SettingItem::new("Bibliography", bibliography).description(
182 "The .bib the library reads and GROBID writes. Empty uses .ragrig/references.bib.",
183 ))
184}
185
186fn chat_group(
187 settings: &Entity<RagSettings>,
188 changes: &Changes,
189 now: &RagChanges,
190 prompt: Option<&PromptActions>,
191) -> SettingGroup {
192 let provider = dropdown_row(
193 settings,
194 changes,
195 spec("rag-provider", RagField::Provider, "Provider"),
196 move |_| provider_options(),
197 |settings| settings.provider.label().to_owned(),
198 |settings, value| {
199 settings.provider = ChatProvider::from_label(&value).unwrap_or(settings.provider);
200 },
201 )
202 .description(format!(
203 "API keys: {}. A missing key is reported when the agent starts; llama.cpp only needs one for a secured server.",
204 ChatProvider::options()
205 .iter()
206 .filter_map(|(provider, _)| provider.env_var())
207 .collect::<Vec<_>>()
208 .join(", ")
209 ));
210 let llamacpp_url = text_row(
211 settings,
212 changes,
213 spec("rag-llamacpp-url", RagField::LlamaCppUrl, "llama.cpp URL"),
214 |settings| settings.llamacpp_url.clone(),
215 |settings, value| settings.llamacpp_url = value,
216 )
217 .description(format!(
218 "Base URL of a local llama-server, including /v1 (default {}). Used when Provider is llama.cpp.",
219 ragrig::agents::DEFAULT_LLAMACPP_BASE_URL
220 ));
221 let model = text_row(
222 settings,
223 changes,
224 spec("rag-model", RagField::Model, "Model"),
225 |settings| settings.model().to_owned(),
226 |settings, value| settings.set_model(value),
227 )
228 .description("Used by the selected provider; each provider keeps its own model.");
229 let stream = switch_row(
230 settings,
231 changes,
232 spec("rag-stream", RagField::Stream, "Stream responses"),
233 |settings| settings.stream,
234 |settings, value| settings.stream = value,
235 );
236 let temperature = number_row(
237 settings,
238 changes,
239 spec("rag-temperature", RagField::Temperature, "Temperature"),
240 NumberFieldOptions {
241 min: 0.0,
242 max: 2.0,
243 step: 0.1,
244 },
245 |settings| settings.temperature,
246 |settings, value| settings.temperature = value.clamp(0.0, 2.0),
247 )
248 .description("0 is deterministic; 2 is very loose.");
249 let max_tokens = token_row(
250 settings,
251 changes,
252 spec("rag-max-tokens", RagField::MaxTokens, "Maximum tokens"),
253 9,
254 20,
255 |settings| settings.max_tokens,
256 |settings, value| settings.max_tokens = value,
257 );
258 let context_tokens = token_row(
259 settings,
260 changes,
261 spec(
262 "rag-context-tokens",
263 RagField::ContextTokens,
264 "Context tokens",
265 ),
266 9,
267 20,
268 |settings| settings.context_tokens,
269 |settings, value| settings.context_tokens = value,
270 );
271 let prompt_path = path_control(
272 "rag-system-prompt-path",
273 "Choose a Markdown prompt file",
274 false,
275 true,
276 {
277 let settings = settings.clone();
278 move |cx| settings.read(cx).system_prompt_path.clone()
279 },
280 {
281 let settings = settings.clone();
282 move |value, cx| {
283 settings.update(cx, |settings, cx| {
284 settings.system_prompt_path = value;
285 cx.notify();
286 });
287 }
288 },
289 );
290 let built_in_name = prompt.map_or("built-in prompt", |prompt| prompt.built_in_name);
291 let system_prompt = SettingRow::new("System prompt", prompt_path)
292 .description(format!(
293 "Empty uses the {built_in_name}. A selected Markdown file overrides it; Apply rebuilds the agent."
294 ))
295 .dirty({
296 let changes = changes.clone();
297 move |cx| changes(cx).contains(RagField::SystemPrompt)
298 })
299 .into_item();
300
301 let mut group = SettingGroup::new()
302 .title(group_title("Chat", now.section(RagSection::Chat)))
303 .item(provider.into_item())
304 .item(llamacpp_url.into_item())
305 .item(model.into_item())
306 .item(stream.into_item())
307 .item(temperature.into_item())
308 .item(max_tokens.into_item())
309 .item(context_tokens.into_item())
310 .item(system_prompt);
311 if let Some(prompt) = prompt {
312 let settings = settings.clone();
313 let prompt = prompt.clone();
314 group = group.item(SettingItem::render(move |_, window, cx| {
315 prompt_buttons(settings.clone(), prompt.clone(), window, cx)
316 }));
317 }
318
319 mark_group(group, now, RagSection::Chat)
320}
321
322fn prompt_buttons(
323 settings: Entity<RagSettings>,
324 actions: PromptActions,
325 _: &mut Window,
326 cx: &mut App,
327) -> AnyElement {
328 let built_in = settings.read(cx).built_in_prompt.clone();
329 let path = settings.read(cx).system_prompt_path.clone();
330 let status = settings.read(cx).status.clone();
331 let uses_built_in =
332 path.trim().is_empty() && settings.read(cx).system_prompt.trim() == built_in.trim();
333
334 let open = {
335 let settings = settings.clone();
336 let actions = actions.clone();
337 Button::new("rag-prompt-open")
338 .label("Open prompt")
339 .icon(gpui_kit::assets::IconName::FolderOpen)
340 .small()
341 .outline()
342 .on_click(move |_, window, cx| {
343 let path = settings.read(cx).prompt_path();
344 (actions.open)(path.as_deref(), window, cx);
345 })
346 };
347 let export = {
348 let settings = settings.clone();
349 let actions = actions.clone();
350 let label = format!("Export {}", actions.built_in_name);
351 let built_in_name = actions.built_in_name;
352 Button::new("rag-prompt-export")
353 .label(label)
354 .icon(gpui_kit::assets::IconName::Save)
355 .small()
356 .outline()
357 .on_click(move |_, window, cx| match (actions.export)(window, cx) {
358 Ok(path) => settings.update(cx, |settings, cx| {
359 settings.system_prompt_path = path;
360 settings.status = Some(format!(
361 "Using {built_in_name} — edit it, then press Apply to rebuild the agent"
362 ));
363 cx.notify();
364 }),
365 Err(message) => settings.update(cx, |settings, cx| {
366 settings.status = Some(message);
367 cx.notify();
368 }),
369 })
370 };
371 let reset = (!uses_built_in).then(|| {
372 let settings = settings.clone();
373 Button::new("rag-prompt-built-in")
374 .label("Use built-in")
375 .icon(gpui_kit::assets::IconName::RefreshCw)
376 .small()
377 .ghost()
378 .on_click(move |_, _, cx| {
379 settings.update(cx, |settings, cx| {
380 settings.use_built_in_prompt();
381 cx.notify();
382 });
383 })
384 });
385
386 h_flex()
387 .gap_2()
388 .items_center()
389 .child(open)
390 .child(export)
391 .when_some(reset, |row, reset| row.child(reset))
392 .when_some(status, |row, status| {
393 row.child(
394 div()
395 .text_xs()
396 .text_color(cx.theme().muted_foreground)
397 .child(status),
398 )
399 })
400 .into_any_element()
401}
402
403fn retrieval_group(
404 settings: &Entity<RagSettings>,
405 changes: &Changes,
406 now: &RagChanges,
407) -> SettingGroup {
408 let hybrid = switch_row(
409 settings,
410 changes,
411 spec("rag-hybrid", RagField::Hybrid, "Hybrid retrieval"),
412 |settings| settings.hybrid,
413 |settings, value| settings.hybrid = value,
414 )
415 .description("BM25 plus embeddings. Off skips vector search and needs no embedding model.");
416 let embed_model = text_row(
417 settings,
418 changes,
419 spec("rag-embed-model", RagField::EmbedModel, "Embedding model"),
420 |settings| settings.embed_model.clone(),
421 |settings, value| settings.embed_model = value,
422 );
423 let top_k = number_row(
424 settings,
425 changes,
426 spec("rag-top-k", RagField::TopK, "Top-k chunks"),
427 NumberFieldOptions {
428 min: 1.0,
429 max: 500.0,
430 step: 1.0,
431 },
432 |settings| settings.top_k as f64,
433 |settings, value| settings.top_k = value.round().clamp(1.0, 500.0) as usize,
434 );
435 let threshold = number_row(
436 settings,
437 changes,
438 spec(
439 "rag-threshold",
440 RagField::SimilarityThreshold,
441 "Similarity threshold",
442 ),
443 NumberFieldOptions {
444 min: 0.0,
445 max: 1.0,
446 step: 0.01,
447 },
448 |settings| settings.similarity_threshold,
449 |settings, value| settings.similarity_threshold = value.clamp(0.0, 1.0),
450 )
451 .description("Chunks below this score are dropped.");
452 let corpus_dirs = text_row(
453 settings,
454 changes,
455 spec("rag-corpus-dirs", RagField::CorpusDirs, "Folder corpora"),
456 |settings| settings.corpus_dirs.join(", "),
457 |settings, value| {
458 settings.corpus_dirs = value
459 .split(',')
460 .map(str::trim)
461 .filter(|entry| !entry.is_empty())
462 .map(str::to_owned)
463 .collect();
464 },
465 )
466 .description("Comma-separated name=path pairs.")
467 .vertical();
468
469 mark_group(
470 SettingGroup::new()
471 .title(group_title("Retrieval", now.section(RagSection::Retrieval)))
472 .item(hybrid.into_item())
473 .item(embed_model.into_item())
474 .item(top_k.into_item())
475 .item(threshold.into_item())
476 .item(corpus_dirs.into_item()),
477 now,
478 RagSection::Retrieval,
479 )
480}
481
482fn parsing_group(
483 settings: &Entity<RagSettings>,
484 changes: &Changes,
485 now: &RagChanges,
486) -> SettingGroup {
487 let pdf_options: Vec<(SharedString, SharedString)> = PdfBackend::options()
488 .iter()
489 .map(|(backend, label)| {
490 (
491 SharedString::from(backend.label()),
492 SharedString::from(*label),
493 )
494 })
495 .collect();
496 let pdf_backend = dropdown_row(
497 settings,
498 changes,
499 spec("rag-pdf-backend", RagField::PdfBackend, "PDF backend"),
500 move |_| pdf_options.clone(),
501 |settings| settings.pdf_backend.label().to_owned(),
502 |settings, value| {
503 settings.pdf_backend = PdfBackend::options()
504 .iter()
505 .find(|(backend, _)| backend.label() == value)
506 .map_or(PdfBackend::Extract, |(backend, _)| *backend);
507 },
508 )
509 .description("Primary parser for PDF documents.");
510 let sloppy_pdf = switch_row(
511 settings,
512 changes,
513 spec("rag-sloppy-pdf", RagField::SloppyPdf, "Sloppy fallback"),
514 |settings| settings.sloppy_pdf,
515 |settings, value| settings.sloppy_pdf = value,
516 )
517 .description("Keep the panic-free PDF scavenger as a fallback.");
518 let chunk_size = number_row(
519 settings,
520 changes,
521 spec("rag-chunk-size", RagField::ChunkSize, "Chunk size"),
522 NumberFieldOptions {
523 min: 16.0,
524 max: 32_768.0,
525 step: 16.0,
526 },
527 |settings| settings.chunk_size as f64,
528 |settings, value| settings.chunk_size = value.round().clamp(16.0, 32_768.0) as usize,
529 );
530 let chunk_overlap = number_row(
531 settings,
532 changes,
533 spec("rag-chunk-overlap", RagField::ChunkOverlap, "Chunk overlap"),
534 NumberFieldOptions {
535 min: 0.0,
536 max: 8_192.0,
537 step: 8.0,
538 },
539 |settings| settings.chunk_overlap as f64,
540 |settings, value| {
541 settings.chunk_overlap = value.round().clamp(0.0, 8_192.0) as usize;
542 },
543 );
544
545 mark_group(
546 SettingGroup::new()
547 .title(group_title(
548 "Parsing and chunking",
549 now.section(RagSection::Parsing),
550 ))
551 .item(pdf_backend.into_item())
552 .item(sloppy_pdf.into_item())
553 .item(chunk_size.into_item())
554 .item(chunk_overlap.into_item())
555 .footer(|_, cx| {
556 div()
557 .text_xs()
558 .text_color(cx.theme().muted_foreground)
559 .child("Changing chunking only affects new ingestion, not stored chunks.")
560 }),
561 now,
562 RagSection::Parsing,
563 )
564}
565
566fn memory_group(
567 settings: &Entity<RagSettings>,
568 changes: &Changes,
569 now: &RagChanges,
570) -> SettingGroup {
571 let memory_options: Vec<(SharedString, SharedString)> = MemoryChoice::options()
572 .iter()
573 .map(|(choice, label)| {
574 (
575 SharedString::from(choice.label()),
576 SharedString::from(*label),
577 )
578 })
579 .collect();
580 let memory = dropdown_row(
581 settings,
582 changes,
583 spec("rag-memory", RagField::Memory, "Strategy"),
584 move |_| memory_options.clone(),
585 |settings| settings.memory.label().to_owned(),
586 |settings, value| {
587 settings.memory = MemoryChoice::options()
588 .iter()
589 .find(|(choice, _)| choice.label() == value)
590 .map_or(MemoryChoice::Rewrite, |(choice, _)| *choice);
591 },
592 );
593 let memory_model = text_row(
594 settings,
595 changes,
596 spec("rag-memory-model", RagField::MemoryModel, "Memory model"),
597 |settings| settings.memory_model.clone(),
598 |settings, value| settings.memory_model = value,
599 )
600 .description("Small local model used for rewriting and summaries.");
601
602 mark_group(
603 SettingGroup::new()
604 .title(group_title("Memory", now.section(RagSection::Memory)))
605 .item(memory.into_item())
606 .item(memory_model.into_item()),
607 now,
608 RagSection::Memory,
609 )
610}
611
612pub fn rag_references_group(settings: &Entity<RagSettings>, changes: &Changes) -> SettingGroup {
618 let grobid_url = text_row(
619 settings,
620 changes,
621 spec("rag-grobid-url", RagField::GrobidUrl, "GROBID server"),
622 |settings| settings.grobid_url.clone(),
623 |settings, value| settings.grobid_url = value,
624 )
625 .description("Used when parsing a folder's PDFs into the bibliography.");
626 let (rename_pdfs, openalex, resolve_citations) = reference_processing_rows(settings, changes);
627
628 SettingGroup::new()
629 .title("References")
630 .item(grobid_url.into_item())
631 .item(rename_pdfs.into_item())
632 .item(openalex.into_item())
633 .item(resolve_citations.into_item())
634 .footer(|_, cx| {
635 div()
636 .text_xs()
637 .text_color(cx.theme().muted_foreground)
638 .child(
639 "Records merge into the Library bibliography (default .ragrig/references.bib).",
640 )
641 })
642}
643
644pub fn rag_reference_processing_group(
647 settings: &Entity<RagSettings>,
648 changes: &Changes,
649) -> SettingGroup {
650 let (rename_pdfs, openalex, resolve_citations) = reference_processing_rows(settings, changes);
651
652 SettingGroup::new()
653 .title("References")
654 .item(rename_pdfs.into_item())
655 .item(openalex.into_item())
656 .item(resolve_citations.into_item())
657 .footer(|_, cx| {
658 div()
659 .text_xs()
660 .text_color(cx.theme().muted_foreground)
661 .child(
662 "Records merge into the Library bibliography (default .ragrig/references.bib).",
663 )
664 })
665}
666
667fn reference_processing_rows(
668 settings: &Entity<RagSettings>,
669 changes: &Changes,
670) -> (SettingRow, SettingRow, SettingRow) {
671 let rename_pdfs = switch_row(
672 settings,
673 changes,
674 spec("rag-rename-pdfs", RagField::RenamePdfs, "Rename PDFs"),
675 |settings| settings.rename_pdfs,
676 |settings, value| settings.rename_pdfs = value,
677 )
678 .description("Rename processed PDFs to Key - Authors - Title - Year.");
679 let openalex = switch_row(
680 settings,
681 changes,
682 spec("rag-openalex", RagField::OpenAlex, "OpenAlex completion"),
683 |settings| settings.openalex,
684 |settings, value| settings.openalex = value,
685 )
686 .description("Fill missing DOI and venue data from OpenAlex.");
687 let resolve_citations = switch_row(
688 settings,
689 changes,
690 spec(
691 "rag-resolve-citations",
692 RagField::ResolveCitations,
693 "Use @key citations",
694 ),
695 |settings| settings.resolve_citations,
696 |settings, value| settings.resolve_citations = value,
697 )
698 .description("Parse @key papers in a question and attach them as context.");
699
700 (rename_pdfs, openalex, resolve_citations)
701}
702
703fn profiles_group(settings: &Entity<RagSettings>, cx: &App) -> SettingGroup {
704 let profile_name = input_field(
705 settings,
706 |settings| settings.profile_name.clone(),
707 |settings, value| settings.profile_name = value,
708 );
709 let profile_choice = dropdown_field(
710 settings,
711 profile_options(settings, cx),
712 |settings| settings.profile_name.clone(),
713 |settings, value| settings.profile_name = value,
714 );
715 let profile_actions = SettingItem::render({
716 let settings = settings.clone();
717 move |_, _, cx| profile_buttons(settings.clone(), cx)
718 });
719
720 SettingGroup::new()
721 .title("Profiles")
722 .item(SettingItem::new("Profile name", profile_name))
723 .item(SettingItem::new("Load profile", profile_choice))
724 .item(profile_actions)
725 .footer(|_, cx| {
726 div()
727 .text_xs()
728 .text_color(cx.theme().muted_foreground)
729 .child("Profiles are JSON files in .ragrig/profiles; secrets are never written.")
730 })
731}
732
733fn apply_group(settings: &Entity<RagSettings>, controller: &Entity<RagController>) -> SettingGroup {
734 let settings = settings.clone();
735 let controller = controller.clone();
736 let apply =
737 SettingItem::render(move |_, _, cx| apply_row(settings.clone(), controller.clone(), cx));
738 SettingGroup::new().title("Apply").item(apply)
739}
740
741pub fn rag_settings(
743 id: impl Into<ElementId>,
744 settings: Entity<RagSettings>,
745 controller: Entity<RagController>,
746 cx: &App,
747) -> Settings {
748 Settings::new(id).page(rag_settings_page(settings, controller, cx))
749}
750
751fn profile_buttons(settings: Entity<RagSettings>, cx: &mut App) -> AnyElement {
752 let save = {
753 let settings = settings.clone();
754 Button::new("rag-profile-save")
755 .label("Save")
756 .icon(gpui_kit::assets::IconName::Save)
757 .small()
758 .outline()
759 .on_click(move |_, _, cx| {
760 settings.update(cx, |settings, cx| {
761 if let Err(message) = settings.save_profile() {
762 settings.status = Some(message);
763 }
764 cx.notify();
765 });
766 })
767 };
768 let load = {
769 let settings = settings.clone();
770 Button::new("rag-profile-load")
771 .label("Load")
772 .icon(gpui_kit::assets::IconName::FolderOpen)
773 .small()
774 .outline()
775 .on_click(move |_, _, cx| {
776 let name = settings.read(cx).profile_name.trim().to_owned();
777 if name.is_empty() {
778 settings.update(cx, |settings, cx| {
779 settings.status = Some("choose a profile first".to_owned());
780 cx.notify();
781 });
782 return;
783 }
784 settings.update(cx, |settings, cx| {
785 if let Err(message) = settings.load_profile(&name) {
786 settings.status = Some(message);
787 }
788 cx.notify();
789 });
790 })
791 };
792 let refresh = {
793 let settings = settings.clone();
794 Button::new("rag-profile-refresh")
795 .label("Refresh")
796 .icon(gpui_kit::assets::IconName::RefreshCw)
797 .small()
798 .ghost()
799 .on_click(move |_, _, cx| {
800 settings.update(cx, |settings, cx| {
801 settings.refresh_profiles();
802 cx.notify();
803 });
804 })
805 };
806
807 let status = settings.read(cx).status.clone();
808 h_flex()
809 .gap_2()
810 .items_center()
811 .child(save)
812 .child(load)
813 .child(refresh)
814 .when_some(status, |row, status| {
815 row.child(
816 div()
817 .text_sm()
818 .text_color(cx.theme().muted_foreground)
819 .child(status),
820 )
821 })
822 .into_any_element()
823}
824
825fn apply_row(
826 settings: Entity<RagSettings>,
827 controller: Entity<RagController>,
828 cx: &mut App,
829) -> AnyElement {
830 let apply = {
831 let settings = settings.clone();
832 let controller = controller.clone();
833 Button::new("rag-settings-apply")
834 .label("Apply")
835 .icon(gpui_kit::assets::IconName::Play)
836 .primary()
837 .small()
838 .on_click(move |_, _, cx| match settings.read(cx).to_setup() {
839 Ok(setup) => {
840 controller.update(cx, |controller, cx| controller.apply(setup, cx));
841 settings.update(cx, |settings, cx| {
842 settings.status = None;
843 cx.notify();
844 });
845 }
846 Err(message) => {
847 settings.update(cx, |settings, cx| {
848 settings.status = Some(message);
849 cx.notify();
850 });
851 }
852 })
853 };
854
855 let controller_status = controller.read(cx).status_line();
856 let has_error = controller.read(cx).error().is_some();
857 let status = settings
858 .read(cx)
859 .status
860 .clone()
861 .unwrap_or(controller_status);
862 let color = if has_error {
863 cx.theme().danger
864 } else {
865 cx.theme().muted_foreground
866 };
867
868 h_flex()
869 .gap_3()
870 .items_center()
871 .child(apply)
872 .child(div().text_sm().text_color(color).child(status))
873 .into_any_element()
874}
875
876pub(crate) fn group_title(label: &str, dirty: bool) -> SharedString {
878 if dirty {
879 format!("{label} ●").into()
880 } else {
881 SharedString::from(label)
882 }
883}
884
885fn mark_group(group: SettingGroup, now: &RagChanges, section: RagSection) -> SettingGroup {
887 if !now.section(section) {
888 return group;
889 }
890 group.footer(|_, cx| {
891 div()
892 .text_xs()
893 .font_weight(FontWeight::BOLD)
894 .text_color(dirty_color(cx))
895 .child("● Unsaved changes — press Apply on Overview")
896 })
897}
898
899#[derive(Clone, Copy)]
902struct FieldSpec {
903 key: &'static str,
904 field: RagField,
905 title: &'static str,
906}
907
908fn spec(key: &'static str, field: RagField, title: &'static str) -> FieldSpec {
909 FieldSpec { key, field, title }
910}
911
912fn dirty(changes: &Changes, field: RagField) -> impl Fn(&App) -> bool + 'static {
913 let changes = changes.clone();
914 move |cx| changes(cx).contains(field)
915}
916
917fn text_row<R, W>(
918 settings: &Entity<RagSettings>,
919 changes: &Changes,
920 spec: FieldSpec,
921 read: R,
922 write: W,
923) -> SettingRow
924where
925 R: Fn(&RagSettings) -> String + 'static,
926 W: Fn(&mut RagSettings, String) + 'static,
927{
928 let reader = settings.clone();
929 let writer = settings.clone();
930 let control = text_control(
931 spec.key,
932 move |cx| read(reader.read(cx)),
933 move |value, cx| {
934 writer.update(cx, |settings, cx| {
935 write(settings, value);
936 cx.notify();
937 });
938 },
939 );
940 SettingRow::new(spec.title, control).dirty(dirty(changes, spec.field))
941}
942
943fn number_row<R, W>(
944 settings: &Entity<RagSettings>,
945 changes: &Changes,
946 spec: FieldSpec,
947 options: NumberFieldOptions,
948 read: R,
949 write: W,
950) -> SettingRow
951where
952 R: Fn(&RagSettings) -> f64 + 'static,
953 W: Fn(&mut RagSettings, f64) + 'static,
954{
955 let reader = settings.clone();
956 let writer = settings.clone();
957 let control = number_control(
958 spec.key,
959 options,
960 move |cx| read(reader.read(cx)),
961 move |value, cx| {
962 writer.update(cx, |settings, cx| {
963 write(settings, value);
964 cx.notify();
965 });
966 },
967 );
968 SettingRow::new(spec.title, control).dirty(dirty(changes, spec.field))
969}
970
971fn switch_row<R, W>(
972 settings: &Entity<RagSettings>,
973 changes: &Changes,
974 spec: FieldSpec,
975 read: R,
976 write: W,
977) -> SettingRow
978where
979 R: Fn(&RagSettings) -> bool + 'static,
980 W: Fn(&mut RagSettings, bool) + 'static,
981{
982 let reader = settings.clone();
983 let writer = settings.clone();
984 let control = switch_control(
985 spec.key,
986 move |cx| read(reader.read(cx)),
987 move |value, cx| {
988 writer.update(cx, |settings, cx| {
989 write(settings, value);
990 cx.notify();
991 });
992 },
993 );
994 SettingRow::new(spec.title, control).dirty(dirty(changes, spec.field))
995}
996
997fn dropdown_row<O, R, W>(
998 settings: &Entity<RagSettings>,
999 changes: &Changes,
1000 spec: FieldSpec,
1001 options: O,
1002 read: R,
1003 write: W,
1004) -> SettingRow
1005where
1006 O: Fn(&App) -> Vec<(SharedString, SharedString)> + 'static,
1007 R: Fn(&RagSettings) -> String + 'static,
1008 W: Fn(&mut RagSettings, String) + 'static,
1009{
1010 let reader = settings.clone();
1011 let writer = settings.clone();
1012 let control = dropdown_control(
1013 spec.key,
1014 options,
1015 move |cx| read(reader.read(cx)),
1016 move |value, cx| {
1017 writer.update(cx, |settings, cx| {
1018 write(settings, value);
1019 cx.notify();
1020 });
1021 },
1022 );
1023 SettingRow::new(spec.title, control).dirty(dirty(changes, spec.field))
1024}
1025
1026fn token_row<R, W>(
1027 settings: &Entity<RagSettings>,
1028 changes: &Changes,
1029 spec: FieldSpec,
1030 min_exponent: u32,
1031 max_exponent: u32,
1032 read: R,
1033 write: W,
1034) -> SettingRow
1035where
1036 R: Fn(&RagSettings) -> usize + 'static,
1037 W: Fn(&mut RagSettings, usize) + 'static,
1038{
1039 let reader = settings.clone();
1040 let writer = settings.clone();
1041 let control = token_control(
1042 spec.key,
1043 min_exponent,
1044 max_exponent,
1045 move |cx| read(reader.read(cx)),
1046 move |value, cx| {
1047 writer.update(cx, |settings, cx| {
1048 write(settings, value);
1049 cx.notify();
1050 });
1051 },
1052 );
1053 SettingRow::new(spec.title, control).dirty(dirty(changes, spec.field))
1054}
1055
1056fn profile_options(settings: &Entity<RagSettings>, cx: &App) -> Vec<(SharedString, SharedString)> {
1057 settings
1058 .read(cx)
1059 .profiles
1060 .iter()
1061 .filter(|name| !name.is_empty())
1062 .map(|name| {
1063 (
1064 SharedString::from(name.clone()),
1065 SharedString::from(name.clone()),
1066 )
1067 })
1068 .collect()
1069}
1070
1071fn input_field<R, W>(
1072 settings: &Entity<RagSettings>,
1073 read: R,
1074 write: W,
1075) -> impl AnySettingField + use<R, W>
1076where
1077 R: Fn(&RagSettings) -> String + 'static,
1078 W: Fn(&mut RagSettings, String) + 'static,
1079{
1080 let reader = settings.clone();
1081 let writer = settings.clone();
1082 SettingField::input(
1083 move |cx: &App| SharedString::from(read(reader.read(cx))),
1084 move |value: SharedString, cx: &mut App| {
1085 writer.update(cx, |settings, cx| {
1086 write(settings, value.to_string());
1087 cx.notify();
1088 });
1089 },
1090 )
1091}
1092
1093fn dropdown_field<R, W>(
1094 settings: &Entity<RagSettings>,
1095 options: Vec<(SharedString, SharedString)>,
1096 read: R,
1097 write: W,
1098) -> impl AnySettingField + use<R, W>
1099where
1100 R: Fn(&RagSettings) -> String + 'static,
1101 W: Fn(&mut RagSettings, String) + 'static,
1102{
1103 let reader = settings.clone();
1104 let writer = settings.clone();
1105 SettingField::dropdown(
1106 options,
1107 move |cx: &App| SharedString::from(read(reader.read(cx))),
1108 move |value: SharedString, cx: &mut App| {
1109 writer.update(cx, |settings, cx| {
1110 write(settings, value.to_string());
1111 cx.notify();
1112 });
1113 },
1114 )
1115}
1116
1117fn provider_options() -> Vec<(SharedString, SharedString)> {
1118 ChatProvider::options()
1119 .iter()
1120 .map(|(provider, label)| {
1121 (
1122 SharedString::from(provider.label()),
1123 SharedString::from(*label),
1124 )
1125 })
1126 .collect()
1127}