1use crate::{
2 slash_command::SlashCommandCompletionProvider, AssistantPanel, InlineAssist, InlineAssistant,
3};
4use anyhow::{anyhow, Result};
5use assets::Assets;
6use chrono::{DateTime, Utc};
7use collections::{HashMap, HashSet};
8use editor::{actions::Tab, CurrentLineHighlight, Editor, EditorElement, EditorEvent, EditorStyle};
9use futures::{
10 future::{self, BoxFuture, Shared},
11 FutureExt,
12};
13use fuzzy::StringMatchCandidate;
14use gpui::{
15 actions, point, size, transparent_black, AppContext, AssetSource, BackgroundExecutor, Bounds,
16 EventEmitter, Global, HighlightStyle, PromptLevel, ReadGlobal, Subscription, Task, TextStyle,
17 TitlebarOptions, UpdateGlobal, View, WindowBounds, WindowHandle, WindowOptions,
18};
19use heed::{
20 types::{SerdeBincode, SerdeJson, Str},
21 Database, RoTxn,
22};
23use language::{language_settings::SoftWrap, Buffer, LanguageRegistry};
24use language_model::{
25 LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, Role,
26};
27use parking_lot::RwLock;
28use picker::{Picker, PickerDelegate};
29use rope::Rope;
30use serde::{Deserialize, Serialize};
31use settings::Settings;
32use std::{
33 cmp::Reverse,
34 future::Future,
35 path::PathBuf,
36 sync::{atomic::AtomicBool, Arc},
37 time::Duration,
38};
39use text::LineEnding;
40use theme::ThemeSettings;
41use ui::{
42 div, prelude::*, IconButtonShape, ListItem, ListItemSpacing, ParentElement, Render,
43 SharedString, Styled, Tooltip, ViewContext, VisualContext,
44};
45use util::{ResultExt, TryFutureExt};
46use uuid::Uuid;
47use workspace::Workspace;
48
49actions!(
50 prompt_library,
51 [
52 NewPrompt,
53 DeletePrompt,
54 DuplicatePrompt,
55 ToggleDefaultPrompt
56 ]
57);
58
59/// Init starts loading the PromptStore in the background and assigns
60/// a shared future to a global.
61pub fn init(cx: &mut AppContext) {
62 let db_path = paths::prompts_dir().join("prompts-library-db.0.mdb");
63 let prompt_store_future = PromptStore::new(db_path, cx.background_executor().clone())
64 .then(|result| future::ready(result.map(Arc::new).map_err(Arc::new)))
65 .boxed()
66 .shared();
67 cx.set_global(GlobalPromptStore(prompt_store_future))
68}
69
70const BUILT_IN_TOOLTIP_TEXT: &'static str = concat!(
71 "This prompt supports special functionality.\n",
72 "It's read-only, but you can remove it from your default prompt."
73);
74
75/// This function opens a new prompt library window if one doesn't exist already.
76/// If one exists, it brings it to the foreground.
77///
78/// Note that, when opening a new window, this waits for the PromptStore to be
79/// initialized. If it was initialized successfully, it returns a window handle
80/// to a prompt library.
81pub fn open_prompt_library(
82 language_registry: Arc<LanguageRegistry>,
83 cx: &mut AppContext,
84) -> Task<Result<WindowHandle<PromptLibrary>>> {
85 let existing_window = cx
86 .windows()
87 .into_iter()
88 .find_map(|window| window.downcast::<PromptLibrary>());
89 if let Some(existing_window) = existing_window {
90 existing_window
91 .update(cx, |_, cx| cx.activate_window())
92 .ok();
93 Task::ready(Ok(existing_window))
94 } else {
95 let store = PromptStore::global(cx);
96 cx.spawn(|cx| async move {
97 let store = store.await?;
98 cx.update(|cx| {
99 let bounds = Bounds::centered(None, size(px(1024.0), px(768.0)), cx);
100 cx.open_window(
101 WindowOptions {
102 titlebar: Some(TitlebarOptions {
103 title: Some("Prompt Library".into()),
104 appears_transparent: true,
105 traffic_light_position: Some(point(px(9.0), px(9.0))),
106 }),
107 window_bounds: Some(WindowBounds::Windowed(bounds)),
108 ..Default::default()
109 },
110 |cx| cx.new_view(|cx| PromptLibrary::new(store, language_registry, cx)),
111 )
112 })?
113 })
114 }
115}
116
117pub struct PromptLibrary {
118 store: Arc<PromptStore>,
119 language_registry: Arc<LanguageRegistry>,
120 prompt_editors: HashMap<PromptId, PromptEditor>,
121 active_prompt_id: Option<PromptId>,
122 picker: View<Picker<PromptPickerDelegate>>,
123 pending_load: Task<()>,
124 _subscriptions: Vec<Subscription>,
125}
126
127struct PromptEditor {
128 title_editor: View<Editor>,
129 body_editor: View<Editor>,
130 token_count: Option<usize>,
131 pending_token_count: Task<Option<()>>,
132 next_title_and_body_to_save: Option<(String, Rope)>,
133 pending_save: Option<Task<Option<()>>>,
134 _subscriptions: Vec<Subscription>,
135}
136
137struct PromptPickerDelegate {
138 store: Arc<PromptStore>,
139 selected_index: usize,
140 matches: Vec<PromptMetadata>,
141}
142
143enum PromptPickerEvent {
144 Selected { prompt_id: PromptId },
145 Confirmed { prompt_id: PromptId },
146 Deleted { prompt_id: PromptId },
147 ToggledDefault { prompt_id: PromptId },
148}
149
150impl EventEmitter<PromptPickerEvent> for Picker<PromptPickerDelegate> {}
151
152impl PickerDelegate for PromptPickerDelegate {
153 type ListItem = ListItem;
154
155 fn match_count(&self) -> usize {
156 self.matches.len()
157 }
158
159 fn selected_index(&self) -> usize {
160 self.selected_index
161 }
162
163 fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext<Picker<Self>>) {
164 self.selected_index = ix;
165 if let Some(prompt) = self.matches.get(self.selected_index) {
166 cx.emit(PromptPickerEvent::Selected {
167 prompt_id: prompt.id,
168 });
169 }
170 }
171
172 fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc<str> {
173 "Search...".into()
174 }
175
176 fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> Task<()> {
177 let search = self.store.search(query);
178 let prev_prompt_id = self.matches.get(self.selected_index).map(|mat| mat.id);
179 cx.spawn(|this, mut cx| async move {
180 let (matches, selected_index) = cx
181 .background_executor()
182 .spawn(async move {
183 let matches = search.await;
184
185 let selected_index = prev_prompt_id
186 .and_then(|prev_prompt_id| {
187 matches.iter().position(|entry| entry.id == prev_prompt_id)
188 })
189 .unwrap_or(0);
190 (matches, selected_index)
191 })
192 .await;
193
194 this.update(&mut cx, |this, cx| {
195 this.delegate.matches = matches;
196 this.delegate.set_selected_index(selected_index, cx);
197 cx.notify();
198 })
199 .ok();
200 })
201 }
202
203 fn confirm(&mut self, _secondary: bool, cx: &mut ViewContext<Picker<Self>>) {
204 if let Some(prompt) = self.matches.get(self.selected_index) {
205 cx.emit(PromptPickerEvent::Confirmed {
206 prompt_id: prompt.id,
207 });
208 }
209 }
210
211 fn dismissed(&mut self, _cx: &mut ViewContext<Picker<Self>>) {}
212
213 fn render_match(
214 &self,
215 ix: usize,
216 selected: bool,
217 cx: &mut ViewContext<Picker<Self>>,
218 ) -> Option<Self::ListItem> {
219 let prompt = self.matches.get(ix)?;
220 let default = prompt.default;
221 let prompt_id = prompt.id;
222 let element = ListItem::new(ix)
223 .inset(true)
224 .spacing(ListItemSpacing::Sparse)
225 .selected(selected)
226 .child(h_flex().h_5().line_height(relative(1.)).child(Label::new(
227 prompt.title.clone().unwrap_or("Untitled".into()),
228 )))
229 .end_slot::<IconButton>(default.then(|| {
230 IconButton::new("toggle-default-prompt", IconName::SparkleFilled)
231 .selected(true)
232 .icon_color(Color::Accent)
233 .shape(IconButtonShape::Square)
234 .tooltip(move |cx| Tooltip::text("Remove from Default Prompt", cx))
235 .on_click(cx.listener(move |_, _, cx| {
236 cx.emit(PromptPickerEvent::ToggledDefault { prompt_id })
237 }))
238 }))
239 .end_hover_slot(
240 h_flex()
241 .gap_2()
242 .child(if prompt_id.is_built_in() {
243 div()
244 .id("built-in-prompt")
245 .child(Icon::new(IconName::FileLock).color(Color::Muted))
246 .tooltip(move |cx| {
247 Tooltip::with_meta(
248 "Built-in prompt",
249 None,
250 BUILT_IN_TOOLTIP_TEXT,
251 cx,
252 )
253 })
254 .into_any()
255 } else {
256 IconButton::new("delete-prompt", IconName::Trash)
257 .icon_color(Color::Muted)
258 .shape(IconButtonShape::Square)
259 .tooltip(move |cx| Tooltip::text("Delete Prompt", cx))
260 .on_click(cx.listener(move |_, _, cx| {
261 cx.emit(PromptPickerEvent::Deleted { prompt_id })
262 }))
263 .into_any_element()
264 })
265 .child(
266 IconButton::new("toggle-default-prompt", IconName::Sparkle)
267 .selected(default)
268 .selected_icon(IconName::SparkleFilled)
269 .icon_color(if default { Color::Accent } else { Color::Muted })
270 .shape(IconButtonShape::Square)
271 .tooltip(move |cx| {
272 Tooltip::text(
273 if default {
274 "Remove from Default Prompt"
275 } else {
276 "Add to Default Prompt"
277 },
278 cx,
279 )
280 })
281 .on_click(cx.listener(move |_, _, cx| {
282 cx.emit(PromptPickerEvent::ToggledDefault { prompt_id })
283 })),
284 ),
285 );
286 Some(element)
287 }
288
289 fn render_editor(&self, editor: &View<Editor>, cx: &mut ViewContext<Picker<Self>>) -> Div {
290 h_flex()
291 .bg(cx.theme().colors().editor_background)
292 .rounded_md()
293 .overflow_hidden()
294 .flex_none()
295 .py_1()
296 .px_2()
297 .mx_1()
298 .child(editor.clone())
299 }
300}
301
302impl PromptLibrary {
303 fn new(
304 store: Arc<PromptStore>,
305 language_registry: Arc<LanguageRegistry>,
306 cx: &mut ViewContext<Self>,
307 ) -> Self {
308 let delegate = PromptPickerDelegate {
309 store: store.clone(),
310 selected_index: 0,
311 matches: Vec::new(),
312 };
313
314 let picker = cx.new_view(|cx| {
315 let picker = Picker::uniform_list(delegate, cx)
316 .modal(false)
317 .max_height(None);
318 picker.focus(cx);
319 picker
320 });
321 Self {
322 store: store.clone(),
323 language_registry,
324 prompt_editors: HashMap::default(),
325 active_prompt_id: None,
326 pending_load: Task::ready(()),
327 _subscriptions: vec![cx.subscribe(&picker, Self::handle_picker_event)],
328 picker,
329 }
330 }
331
332 fn handle_picker_event(
333 &mut self,
334 _: View<Picker<PromptPickerDelegate>>,
335 event: &PromptPickerEvent,
336 cx: &mut ViewContext<Self>,
337 ) {
338 match event {
339 PromptPickerEvent::Selected { prompt_id } => {
340 self.load_prompt(*prompt_id, false, cx);
341 }
342 PromptPickerEvent::Confirmed { prompt_id } => {
343 self.load_prompt(*prompt_id, true, cx);
344 }
345 PromptPickerEvent::ToggledDefault { prompt_id } => {
346 self.toggle_default_for_prompt(*prompt_id, cx);
347 }
348 PromptPickerEvent::Deleted { prompt_id } => {
349 self.delete_prompt(*prompt_id, cx);
350 }
351 }
352 }
353
354 pub fn new_prompt(&mut self, cx: &mut ViewContext<Self>) {
355 // If we already have an untitled prompt, use that instead
356 // of creating a new one.
357 if let Some(metadata) = self.store.first() {
358 if metadata.title.is_none() {
359 self.load_prompt(metadata.id, true, cx);
360 return;
361 }
362 }
363
364 let prompt_id = PromptId::new();
365 let save = self.store.save(prompt_id, None, false, "".into());
366 self.picker.update(cx, |picker, cx| picker.refresh(cx));
367 cx.spawn(|this, mut cx| async move {
368 save.await?;
369 this.update(&mut cx, |this, cx| this.load_prompt(prompt_id, true, cx))
370 })
371 .detach_and_log_err(cx);
372 }
373
374 pub fn save_prompt(&mut self, prompt_id: PromptId, cx: &mut ViewContext<Self>) {
375 const SAVE_THROTTLE: Duration = Duration::from_millis(500);
376
377 if prompt_id.is_built_in() {
378 return;
379 }
380
381 let prompt_metadata = self.store.metadata(prompt_id).unwrap();
382 let prompt_editor = self.prompt_editors.get_mut(&prompt_id).unwrap();
383 let title = prompt_editor.title_editor.read(cx).text(cx);
384 let body = prompt_editor.body_editor.update(cx, |editor, cx| {
385 editor
386 .buffer()
387 .read(cx)
388 .as_singleton()
389 .unwrap()
390 .read(cx)
391 .as_rope()
392 .clone()
393 });
394
395 let store = self.store.clone();
396 let executor = cx.background_executor().clone();
397
398 prompt_editor.next_title_and_body_to_save = Some((title, body));
399 if prompt_editor.pending_save.is_none() {
400 prompt_editor.pending_save = Some(cx.spawn(|this, mut cx| {
401 async move {
402 loop {
403 let title_and_body = this.update(&mut cx, |this, _| {
404 this.prompt_editors
405 .get_mut(&prompt_id)?
406 .next_title_and_body_to_save
407 .take()
408 })?;
409
410 if let Some((title, body)) = title_and_body {
411 let title = if title.trim().is_empty() {
412 None
413 } else {
414 Some(SharedString::from(title))
415 };
416 store
417 .save(prompt_id, title, prompt_metadata.default, body)
418 .await
419 .log_err();
420 this.update(&mut cx, |this, cx| {
421 this.picker.update(cx, |picker, cx| picker.refresh(cx));
422 cx.notify();
423 })?;
424
425 executor.timer(SAVE_THROTTLE).await;
426 } else {
427 break;
428 }
429 }
430
431 this.update(&mut cx, |this, _cx| {
432 if let Some(prompt_editor) = this.prompt_editors.get_mut(&prompt_id) {
433 prompt_editor.pending_save = None;
434 }
435 })
436 }
437 .log_err()
438 }));
439 }
440 }
441
442 pub fn delete_active_prompt(&mut self, cx: &mut ViewContext<Self>) {
443 if let Some(active_prompt_id) = self.active_prompt_id {
444 self.delete_prompt(active_prompt_id, cx);
445 }
446 }
447
448 pub fn duplicate_active_prompt(&mut self, cx: &mut ViewContext<Self>) {
449 if let Some(active_prompt_id) = self.active_prompt_id {
450 self.duplicate_prompt(active_prompt_id, cx);
451 }
452 }
453
454 pub fn toggle_default_for_active_prompt(&mut self, cx: &mut ViewContext<Self>) {
455 if let Some(active_prompt_id) = self.active_prompt_id {
456 self.toggle_default_for_prompt(active_prompt_id, cx);
457 }
458 }
459
460 pub fn toggle_default_for_prompt(&mut self, prompt_id: PromptId, cx: &mut ViewContext<Self>) {
461 if let Some(prompt_metadata) = self.store.metadata(prompt_id) {
462 self.store
463 .save_metadata(prompt_id, prompt_metadata.title, !prompt_metadata.default)
464 .detach_and_log_err(cx);
465 self.picker.update(cx, |picker, cx| picker.refresh(cx));
466 cx.notify();
467 }
468 }
469
470 pub fn load_prompt(&mut self, prompt_id: PromptId, focus: bool, cx: &mut ViewContext<Self>) {
471 if let Some(prompt_editor) = self.prompt_editors.get(&prompt_id) {
472 if focus {
473 prompt_editor
474 .body_editor
475 .update(cx, |editor, cx| editor.focus(cx));
476 }
477 self.set_active_prompt(Some(prompt_id), cx);
478 } else if let Some(prompt_metadata) = self.store.metadata(prompt_id) {
479 let language_registry = self.language_registry.clone();
480 let prompt = self.store.load(prompt_id);
481 self.pending_load = cx.spawn(|this, mut cx| async move {
482 let prompt = prompt.await;
483 let markdown = language_registry.language_for_name("Markdown").await;
484 this.update(&mut cx, |this, cx| match prompt {
485 Ok(prompt) => {
486 let title_editor = cx.new_view(|cx| {
487 let mut editor = Editor::auto_width(cx);
488 editor.set_placeholder_text("Untitled", cx);
489 editor.set_text(prompt_metadata.title.unwrap_or_default(), cx);
490 editor.set_read_only(prompt_id.is_built_in());
491 editor
492 });
493 let body_editor = cx.new_view(|cx| {
494 let buffer = cx.new_model(|cx| {
495 let mut buffer = Buffer::local(prompt, cx);
496 buffer.set_language(markdown.log_err(), cx);
497 buffer.set_language_registry(language_registry);
498 buffer
499 });
500
501 let mut editor = Editor::for_buffer(buffer, None, cx);
502 editor.set_read_only(prompt_id.is_built_in());
503 editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
504 editor.set_show_gutter(false, cx);
505 editor.set_show_wrap_guides(false, cx);
506 editor.set_show_indent_guides(false, cx);
507 editor.set_use_modal_editing(false);
508 editor.set_current_line_highlight(Some(CurrentLineHighlight::None));
509 editor.set_completion_provider(Box::new(
510 SlashCommandCompletionProvider::new(None, None),
511 ));
512 if focus {
513 editor.focus(cx);
514 }
515 editor
516 });
517 let _subscriptions = vec![
518 cx.subscribe(&title_editor, move |this, editor, event, cx| {
519 this.handle_prompt_title_editor_event(prompt_id, editor, event, cx)
520 }),
521 cx.subscribe(&body_editor, move |this, editor, event, cx| {
522 this.handle_prompt_body_editor_event(prompt_id, editor, event, cx)
523 }),
524 ];
525 this.prompt_editors.insert(
526 prompt_id,
527 PromptEditor {
528 title_editor,
529 body_editor,
530 next_title_and_body_to_save: None,
531 pending_save: None,
532 token_count: None,
533 pending_token_count: Task::ready(None),
534 _subscriptions,
535 },
536 );
537 this.set_active_prompt(Some(prompt_id), cx);
538 this.count_tokens(prompt_id, cx);
539 }
540 Err(error) => {
541 // TODO: we should show the error in the UI.
542 log::error!("error while loading prompt: {:?}", error);
543 }
544 })
545 .ok();
546 });
547 }
548 }
549
550 fn set_active_prompt(&mut self, prompt_id: Option<PromptId>, cx: &mut ViewContext<Self>) {
551 self.active_prompt_id = prompt_id;
552 self.picker.update(cx, |picker, cx| {
553 if let Some(prompt_id) = prompt_id {
554 if picker
555 .delegate
556 .matches
557 .get(picker.delegate.selected_index())
558 .map_or(true, |old_selected_prompt| {
559 old_selected_prompt.id != prompt_id
560 })
561 {
562 if let Some(ix) = picker
563 .delegate
564 .matches
565 .iter()
566 .position(|mat| mat.id == prompt_id)
567 {
568 picker.set_selected_index(ix, true, cx);
569 }
570 }
571 } else {
572 picker.focus(cx);
573 }
574 });
575 cx.notify();
576 }
577
578 pub fn delete_prompt(&mut self, prompt_id: PromptId, cx: &mut ViewContext<Self>) {
579 if let Some(metadata) = self.store.metadata(prompt_id) {
580 let confirmation = cx.prompt(
581 PromptLevel::Warning,
582 &format!(
583 "Are you sure you want to delete {}",
584 metadata.title.unwrap_or("Untitled".into())
585 ),
586 None,
587 &["Delete", "Cancel"],
588 );
589
590 cx.spawn(|this, mut cx| async move {
591 if confirmation.await.ok() == Some(0) {
592 this.update(&mut cx, |this, cx| {
593 if this.active_prompt_id == Some(prompt_id) {
594 this.set_active_prompt(None, cx);
595 }
596 this.prompt_editors.remove(&prompt_id);
597 this.store.delete(prompt_id).detach_and_log_err(cx);
598 this.picker.update(cx, |picker, cx| picker.refresh(cx));
599 cx.notify();
600 })?;
601 }
602 anyhow::Ok(())
603 })
604 .detach_and_log_err(cx);
605 }
606 }
607
608 pub fn duplicate_prompt(&mut self, prompt_id: PromptId, cx: &mut ViewContext<Self>) {
609 if let Some(prompt) = self.prompt_editors.get(&prompt_id) {
610 const DUPLICATE_SUFFIX: &str = " copy";
611 let title_to_duplicate = prompt.title_editor.read(cx).text(cx);
612 let existing_titles = self
613 .prompt_editors
614 .iter()
615 .filter(|&(&id, _)| id != prompt_id)
616 .map(|(_, prompt_editor)| prompt_editor.title_editor.read(cx).text(cx))
617 .filter(|title| title.starts_with(&title_to_duplicate))
618 .collect::<HashSet<_>>();
619
620 let title = if existing_titles.is_empty() {
621 title_to_duplicate + DUPLICATE_SUFFIX
622 } else {
623 let mut i = 1;
624 loop {
625 let new_title = format!("{title_to_duplicate}{DUPLICATE_SUFFIX} {i}");
626 if !existing_titles.contains(&new_title) {
627 break new_title;
628 }
629 i += 1;
630 }
631 };
632
633 let new_id = PromptId::new();
634 let body = prompt.body_editor.read(cx).text(cx);
635 let save = self
636 .store
637 .save(new_id, Some(title.into()), false, body.into());
638 self.picker.update(cx, |picker, cx| picker.refresh(cx));
639 cx.spawn(|this, mut cx| async move {
640 save.await?;
641 this.update(&mut cx, |prompt_library, cx| {
642 prompt_library.load_prompt(new_id, true, cx)
643 })
644 })
645 .detach_and_log_err(cx);
646 }
647 }
648
649 fn focus_active_prompt(&mut self, _: &Tab, cx: &mut ViewContext<Self>) {
650 if let Some(active_prompt) = self.active_prompt_id {
651 self.prompt_editors[&active_prompt]
652 .body_editor
653 .update(cx, |editor, cx| editor.focus(cx));
654 cx.stop_propagation();
655 }
656 }
657
658 fn focus_picker(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
659 self.picker.update(cx, |picker, cx| picker.focus(cx));
660 }
661
662 pub fn inline_assist(&mut self, action: &InlineAssist, cx: &mut ViewContext<Self>) {
663 let Some(active_prompt_id) = self.active_prompt_id else {
664 cx.propagate();
665 return;
666 };
667
668 let prompt_editor = &self.prompt_editors[&active_prompt_id].body_editor;
669 let Some(provider) = LanguageModelRegistry::read_global(cx).active_provider() else {
670 return;
671 };
672
673 let initial_prompt = action.prompt.clone();
674 if provider.is_authenticated(cx) {
675 InlineAssistant::update_global(cx, |assistant, cx| {
676 assistant.assist(&prompt_editor, None, None, initial_prompt, cx)
677 })
678 } else {
679 for window in cx.windows() {
680 if let Some(workspace) = window.downcast::<Workspace>() {
681 let panel = workspace
682 .update(cx, |workspace, cx| {
683 cx.activate_window();
684 workspace.focus_panel::<AssistantPanel>(cx)
685 })
686 .ok()
687 .flatten();
688 if panel.is_some() {
689 return;
690 }
691 }
692 }
693 }
694 }
695
696 fn move_down_from_title(&mut self, _: &editor::actions::MoveDown, cx: &mut ViewContext<Self>) {
697 if let Some(prompt_id) = self.active_prompt_id {
698 if let Some(prompt_editor) = self.prompt_editors.get(&prompt_id) {
699 cx.focus_view(&prompt_editor.body_editor);
700 }
701 }
702 }
703
704 fn move_up_from_body(&mut self, _: &editor::actions::MoveUp, cx: &mut ViewContext<Self>) {
705 if let Some(prompt_id) = self.active_prompt_id {
706 if let Some(prompt_editor) = self.prompt_editors.get(&prompt_id) {
707 cx.focus_view(&prompt_editor.title_editor);
708 }
709 }
710 }
711
712 fn handle_prompt_title_editor_event(
713 &mut self,
714 prompt_id: PromptId,
715 title_editor: View<Editor>,
716 event: &EditorEvent,
717 cx: &mut ViewContext<Self>,
718 ) {
719 match event {
720 EditorEvent::BufferEdited => {
721 self.save_prompt(prompt_id, cx);
722 self.count_tokens(prompt_id, cx);
723 }
724 EditorEvent::Blurred => {
725 title_editor.update(cx, |title_editor, cx| {
726 title_editor.change_selections(None, cx, |selections| {
727 let cursor = selections.oldest_anchor().head();
728 selections.select_anchor_ranges([cursor..cursor]);
729 });
730 });
731 }
732 _ => {}
733 }
734 }
735
736 fn handle_prompt_body_editor_event(
737 &mut self,
738 prompt_id: PromptId,
739 body_editor: View<Editor>,
740 event: &EditorEvent,
741 cx: &mut ViewContext<Self>,
742 ) {
743 match event {
744 EditorEvent::BufferEdited => {
745 self.save_prompt(prompt_id, cx);
746 self.count_tokens(prompt_id, cx);
747 }
748 EditorEvent::Blurred => {
749 body_editor.update(cx, |body_editor, cx| {
750 body_editor.change_selections(None, cx, |selections| {
751 let cursor = selections.oldest_anchor().head();
752 selections.select_anchor_ranges([cursor..cursor]);
753 });
754 });
755 }
756 _ => {}
757 }
758 }
759
760 fn count_tokens(&mut self, prompt_id: PromptId, cx: &mut ViewContext<Self>) {
761 let Some(model) = LanguageModelRegistry::read_global(cx).active_model() else {
762 return;
763 };
764 if let Some(prompt) = self.prompt_editors.get_mut(&prompt_id) {
765 let editor = &prompt.body_editor.read(cx);
766 let buffer = &editor.buffer().read(cx).as_singleton().unwrap().read(cx);
767 let body = buffer.as_rope().clone();
768 prompt.pending_token_count = cx.spawn(|this, mut cx| {
769 async move {
770 const DEBOUNCE_TIMEOUT: Duration = Duration::from_secs(1);
771
772 cx.background_executor().timer(DEBOUNCE_TIMEOUT).await;
773 let token_count = cx
774 .update(|cx| {
775 model.count_tokens(
776 LanguageModelRequest {
777 messages: vec![LanguageModelRequestMessage {
778 role: Role::System,
779 content: body.to_string(),
780 }],
781 stop: Vec::new(),
782 temperature: 1.,
783 },
784 cx,
785 )
786 })?
787 .await?;
788
789 this.update(&mut cx, |this, cx| {
790 let prompt_editor = this.prompt_editors.get_mut(&prompt_id).unwrap();
791 prompt_editor.token_count = Some(token_count);
792 cx.notify();
793 })
794 }
795 .log_err()
796 });
797 }
798 }
799
800 fn render_prompt_list(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
801 v_flex()
802 .id("prompt-list")
803 .capture_action(cx.listener(Self::focus_active_prompt))
804 .bg(cx.theme().colors().panel_background)
805 .h_full()
806 .px_1()
807 .w_1_3()
808 .overflow_x_hidden()
809 .child(
810 h_flex()
811 .p(Spacing::Small.rems(cx))
812 .h_9()
813 .w_full()
814 .flex_none()
815 .justify_end()
816 .child(
817 IconButton::new("new-prompt", IconName::Plus)
818 .style(ButtonStyle::Transparent)
819 .shape(IconButtonShape::Square)
820 .tooltip(move |cx| Tooltip::for_action("New Prompt", &NewPrompt, cx))
821 .on_click(|_, cx| {
822 cx.dispatch_action(Box::new(NewPrompt));
823 }),
824 ),
825 )
826 .child(div().flex_grow().child(self.picker.clone()))
827 }
828
829 fn render_active_prompt(&mut self, cx: &mut ViewContext<PromptLibrary>) -> gpui::Stateful<Div> {
830 div()
831 .w_2_3()
832 .h_full()
833 .id("prompt-editor")
834 .border_l_1()
835 .border_color(cx.theme().colors().border)
836 .bg(cx.theme().colors().editor_background)
837 .flex_none()
838 .min_w_64()
839 .children(self.active_prompt_id.and_then(|prompt_id| {
840 let prompt_metadata = self.store.metadata(prompt_id)?;
841 let prompt_editor = &self.prompt_editors[&prompt_id];
842 let focus_handle = prompt_editor.body_editor.focus_handle(cx);
843 let model = LanguageModelRegistry::read_global(cx).active_model();
844 let settings = ThemeSettings::get_global(cx);
845
846 Some(
847 v_flex()
848 .id("prompt-editor-inner")
849 .size_full()
850 .relative()
851 .overflow_hidden()
852 .pl(Spacing::XXLarge.rems(cx))
853 .pt(Spacing::Large.rems(cx))
854 .on_click(cx.listener(move |_, _, cx| {
855 cx.focus(&focus_handle);
856 }))
857 .child(
858 h_flex()
859 .group("active-editor-header")
860 .pr(Spacing::XXLarge.rems(cx))
861 .pt(Spacing::XSmall.rems(cx))
862 .pb(Spacing::Large.rems(cx))
863 .justify_between()
864 .child(
865 h_flex().gap_1().child(
866 div()
867 .max_w_80()
868 .on_action(cx.listener(Self::move_down_from_title))
869 .border_1()
870 .border_color(transparent_black())
871 .rounded_md()
872 .group_hover("active-editor-header", |this| {
873 this.border_color(
874 cx.theme().colors().border_variant,
875 )
876 })
877 .child(EditorElement::new(
878 &prompt_editor.title_editor,
879 EditorStyle {
880 background: cx.theme().system().transparent,
881 local_player: cx.theme().players().local(),
882 text: TextStyle {
883 color: cx
884 .theme()
885 .colors()
886 .editor_foreground,
887 font_family: settings
888 .ui_font
889 .family
890 .clone(),
891 font_features: settings
892 .ui_font
893 .features
894 .clone(),
895 font_size: HeadlineSize::Large
896 .size()
897 .into(),
898 font_weight: settings.ui_font.weight,
899 line_height: relative(
900 settings.buffer_line_height.value(),
901 ),
902 ..Default::default()
903 },
904 scrollbar_width: Pixels::ZERO,
905 syntax: cx.theme().syntax().clone(),
906 status: cx.theme().status().clone(),
907 inlay_hints_style: HighlightStyle {
908 color: Some(cx.theme().status().hint),
909 ..HighlightStyle::default()
910 },
911 suggestions_style: HighlightStyle {
912 color: Some(cx.theme().status().predictive),
913 ..HighlightStyle::default()
914 },
915 },
916 )),
917 ),
918 )
919 .child(
920 h_flex()
921 .h_full()
922 .child(
923 h_flex()
924 .h_full()
925 .gap(Spacing::XXLarge.rems(cx))
926 .child(div()),
927 )
928 .child(
929 h_flex()
930 .h_full()
931 .gap(Spacing::XXLarge.rems(cx))
932 .children(prompt_editor.token_count.map(
933 |token_count| {
934 let token_count: SharedString =
935 token_count.to_string().into();
936 let label_token_count: SharedString =
937 token_count.to_string().into();
938
939 h_flex()
940 .id("token_count")
941 .tooltip(move |cx| {
942 let token_count =
943 token_count.clone();
944
945 Tooltip::with_meta(
946 format!(
947 "{} tokens",
948 token_count.clone()
949 ),
950 None,
951 format!(
952 "Model: {}",
953 model
954 .as_ref()
955 .map(|model| model
956 .name()
957 .0)
958 .unwrap_or_default()
959 ),
960 cx,
961 )
962 })
963 .child(
964 Label::new(format!(
965 "{} tokens",
966 label_token_count.clone()
967 ))
968 .color(Color::Muted),
969 )
970 },
971 ))
972 .child(if prompt_id.is_built_in() {
973 div()
974 .id("built-in-prompt")
975 .child(
976 Icon::new(IconName::FileLock)
977 .color(Color::Muted),
978 )
979 .tooltip(move |cx| {
980 Tooltip::with_meta(
981 "Built-in prompt",
982 None,
983 BUILT_IN_TOOLTIP_TEXT,
984 cx,
985 )
986 })
987 .into_any()
988 } else {
989 IconButton::new(
990 "delete-prompt",
991 IconName::Trash,
992 )
993 .size(ButtonSize::Large)
994 .style(ButtonStyle::Transparent)
995 .shape(IconButtonShape::Square)
996 .size(ButtonSize::Large)
997 .tooltip(move |cx| {
998 Tooltip::for_action(
999 "Delete Prompt",
1000 &DeletePrompt,
1001 cx,
1002 )
1003 })
1004 .on_click(|_, cx| {
1005 cx.dispatch_action(Box::new(DeletePrompt));
1006 })
1007 .into_any_element()
1008 })
1009 .child(
1010 IconButton::new(
1011 "duplicate-prompt",
1012 IconName::BookCopy,
1013 )
1014 .size(ButtonSize::Large)
1015 .style(ButtonStyle::Transparent)
1016 .shape(IconButtonShape::Square)
1017 .size(ButtonSize::Large)
1018 .tooltip(move |cx| {
1019 Tooltip::for_action(
1020 "Duplicate Prompt",
1021 &DuplicatePrompt,
1022 cx,
1023 )
1024 })
1025 .on_click(|_, cx| {
1026 cx.dispatch_action(Box::new(
1027 DuplicatePrompt,
1028 ));
1029 }),
1030 )
1031 .child(
1032 IconButton::new(
1033 "toggle-default-prompt",
1034 IconName::Sparkle,
1035 )
1036 .style(ButtonStyle::Transparent)
1037 .selected(prompt_metadata.default)
1038 .selected_icon(IconName::SparkleFilled)
1039 .icon_color(if prompt_metadata.default {
1040 Color::Accent
1041 } else {
1042 Color::Muted
1043 })
1044 .shape(IconButtonShape::Square)
1045 .size(ButtonSize::Large)
1046 .tooltip(move |cx| {
1047 Tooltip::text(
1048 if prompt_metadata.default {
1049 "Remove from Default Prompt"
1050 } else {
1051 "Add to Default Prompt"
1052 },
1053 cx,
1054 )
1055 })
1056 .on_click(|_, cx| {
1057 cx.dispatch_action(Box::new(
1058 ToggleDefaultPrompt,
1059 ));
1060 }),
1061 ),
1062 ),
1063 ),
1064 )
1065 .child(
1066 div()
1067 .on_action(cx.listener(Self::focus_picker))
1068 .on_action(cx.listener(Self::inline_assist))
1069 .on_action(cx.listener(Self::move_up_from_body))
1070 .flex_grow()
1071 .h_full()
1072 .child(prompt_editor.body_editor.clone()),
1073 ),
1074 )
1075 }))
1076 }
1077}
1078
1079impl Render for PromptLibrary {
1080 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
1081 let ui_font = theme::setup_ui_font(cx);
1082 let theme = cx.theme().clone();
1083
1084 h_flex()
1085 .id("prompt-manager")
1086 .key_context("PromptLibrary")
1087 .on_action(cx.listener(|this, &NewPrompt, cx| this.new_prompt(cx)))
1088 .on_action(cx.listener(|this, &DeletePrompt, cx| this.delete_active_prompt(cx)))
1089 .on_action(cx.listener(|this, &DuplicatePrompt, cx| this.duplicate_active_prompt(cx)))
1090 .on_action(cx.listener(|this, &ToggleDefaultPrompt, cx| {
1091 this.toggle_default_for_active_prompt(cx)
1092 }))
1093 .size_full()
1094 .overflow_hidden()
1095 .font(ui_font)
1096 .text_color(theme.colors().text)
1097 .child(self.render_prompt_list(cx))
1098 .child(self.render_active_prompt(cx))
1099 }
1100}
1101
1102#[derive(Clone, Debug, Serialize, Deserialize)]
1103pub struct PromptMetadata {
1104 pub id: PromptId,
1105 pub title: Option<SharedString>,
1106 pub default: bool,
1107 pub saved_at: DateTime<Utc>,
1108}
1109
1110#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
1111#[serde(tag = "kind")]
1112pub enum PromptId {
1113 User { uuid: Uuid },
1114 EditWorkflow,
1115}
1116
1117impl PromptId {
1118 pub fn new() -> PromptId {
1119 PromptId::User {
1120 uuid: Uuid::new_v4(),
1121 }
1122 }
1123
1124 pub fn is_built_in(&self) -> bool {
1125 !matches!(self, PromptId::User { .. })
1126 }
1127}
1128
1129pub struct PromptStore {
1130 executor: BackgroundExecutor,
1131 env: heed::Env,
1132 metadata_cache: RwLock<MetadataCache>,
1133 metadata: Database<SerdeJson<PromptId>, SerdeJson<PromptMetadata>>,
1134 bodies: Database<SerdeJson<PromptId>, Str>,
1135}
1136
1137#[derive(Default)]
1138struct MetadataCache {
1139 metadata: Vec<PromptMetadata>,
1140 metadata_by_id: HashMap<PromptId, PromptMetadata>,
1141}
1142
1143impl MetadataCache {
1144 fn from_db(
1145 db: Database<SerdeJson<PromptId>, SerdeJson<PromptMetadata>>,
1146 txn: &RoTxn,
1147 ) -> Result<Self> {
1148 let mut cache = MetadataCache::default();
1149 for result in db.iter(txn)? {
1150 let (prompt_id, metadata) = result?;
1151 cache.metadata.push(metadata.clone());
1152 cache.metadata_by_id.insert(prompt_id, metadata);
1153 }
1154 cache.sort();
1155 Ok(cache)
1156 }
1157
1158 fn insert(&mut self, metadata: PromptMetadata) {
1159 self.metadata_by_id.insert(metadata.id, metadata.clone());
1160 if let Some(old_metadata) = self.metadata.iter_mut().find(|m| m.id == metadata.id) {
1161 *old_metadata = metadata;
1162 } else {
1163 self.metadata.push(metadata);
1164 }
1165 self.sort();
1166 }
1167
1168 fn remove(&mut self, id: PromptId) {
1169 self.metadata.retain(|metadata| metadata.id != id);
1170 self.metadata_by_id.remove(&id);
1171 }
1172
1173 fn sort(&mut self) {
1174 self.metadata.sort_unstable_by(|a, b| {
1175 a.title
1176 .cmp(&b.title)
1177 .then_with(|| b.saved_at.cmp(&a.saved_at))
1178 });
1179 }
1180}
1181
1182impl PromptStore {
1183 pub fn global(cx: &AppContext) -> impl Future<Output = Result<Arc<Self>>> {
1184 let store = GlobalPromptStore::global(cx).0.clone();
1185 async move { store.await.map_err(|err| anyhow!(err)) }
1186 }
1187
1188 pub fn new(db_path: PathBuf, executor: BackgroundExecutor) -> Task<Result<Self>> {
1189 executor.spawn({
1190 let executor = executor.clone();
1191 async move {
1192 std::fs::create_dir_all(&db_path)?;
1193
1194 let db_env = unsafe {
1195 heed::EnvOpenOptions::new()
1196 .map_size(1024 * 1024 * 1024) // 1GB
1197 .max_dbs(4) // Metadata and bodies (possibly v1 of both as well)
1198 .open(db_path)?
1199 };
1200
1201 let mut txn = db_env.write_txn()?;
1202 let metadata = db_env.create_database(&mut txn, Some("metadata.v2"))?;
1203 let bodies = db_env.create_database(&mut txn, Some("bodies.v2"))?;
1204 txn.commit()?;
1205
1206 Self::upgrade_dbs(&db_env, metadata, bodies).log_err();
1207
1208 let txn = db_env.read_txn()?;
1209 let metadata_cache = MetadataCache::from_db(metadata, &txn)?;
1210 txn.commit()?;
1211
1212 let store = PromptStore {
1213 executor,
1214 env: db_env,
1215 metadata_cache: RwLock::new(metadata_cache),
1216 metadata,
1217 bodies,
1218 };
1219
1220 store.save_built_in_prompts().log_err();
1221
1222 Ok(store)
1223 }
1224 })
1225 }
1226
1227 fn upgrade_dbs(
1228 env: &heed::Env,
1229 metadata_db: heed::Database<SerdeJson<PromptId>, SerdeJson<PromptMetadata>>,
1230 bodies_db: heed::Database<SerdeJson<PromptId>, Str>,
1231 ) -> Result<()> {
1232 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, Hash)]
1233 pub struct PromptIdV1(Uuid);
1234
1235 #[derive(Clone, Debug, Serialize, Deserialize)]
1236 pub struct PromptMetadataV1 {
1237 pub id: PromptIdV1,
1238 pub title: Option<SharedString>,
1239 pub default: bool,
1240 pub saved_at: DateTime<Utc>,
1241 }
1242
1243 let mut txn = env.write_txn()?;
1244 let Some(bodies_v1_db) = env
1245 .open_database::<SerdeBincode<PromptIdV1>, SerdeBincode<String>>(
1246 &txn,
1247 Some("bodies"),
1248 )?
1249 else {
1250 return Ok(());
1251 };
1252 let mut bodies_v1 = bodies_v1_db
1253 .iter(&txn)?
1254 .collect::<heed::Result<HashMap<_, _>>>()?;
1255
1256 let Some(metadata_v1_db) = env
1257 .open_database::<SerdeBincode<PromptIdV1>, SerdeBincode<PromptMetadataV1>>(
1258 &txn,
1259 Some("metadata"),
1260 )?
1261 else {
1262 return Ok(());
1263 };
1264 let metadata_v1 = metadata_v1_db
1265 .iter(&txn)?
1266 .collect::<heed::Result<HashMap<_, _>>>()?;
1267
1268 for (prompt_id_v1, metadata_v1) in metadata_v1 {
1269 let prompt_id_v2 = PromptId::User {
1270 uuid: prompt_id_v1.0,
1271 };
1272 let Some(body_v1) = bodies_v1.remove(&prompt_id_v1) else {
1273 continue;
1274 };
1275
1276 if metadata_db
1277 .get(&txn, &prompt_id_v2)?
1278 .map_or(true, |metadata_v2| {
1279 metadata_v1.saved_at > metadata_v2.saved_at
1280 })
1281 {
1282 metadata_db.put(
1283 &mut txn,
1284 &prompt_id_v2,
1285 &PromptMetadata {
1286 id: prompt_id_v2,
1287 title: metadata_v1.title.clone(),
1288 default: metadata_v1.default,
1289 saved_at: metadata_v1.saved_at,
1290 },
1291 )?;
1292 bodies_db.put(&mut txn, &prompt_id_v2, &body_v1)?;
1293 }
1294 }
1295
1296 txn.commit()?;
1297
1298 Ok(())
1299 }
1300
1301 pub fn load(&self, id: PromptId) -> Task<Result<String>> {
1302 let env = self.env.clone();
1303 let bodies = self.bodies;
1304 self.executor.spawn(async move {
1305 let txn = env.read_txn()?;
1306 let mut prompt = bodies
1307 .get(&txn, &id)?
1308 .ok_or_else(|| anyhow!("prompt not found"))?
1309 .into();
1310 LineEnding::normalize(&mut prompt);
1311 Ok(prompt)
1312 })
1313 }
1314
1315 pub fn default_prompt_metadata(&self) -> Vec<PromptMetadata> {
1316 return self
1317 .metadata_cache
1318 .read()
1319 .metadata
1320 .iter()
1321 .filter(|metadata| metadata.default)
1322 .cloned()
1323 .collect::<Vec<_>>();
1324 }
1325
1326 pub fn delete(&self, id: PromptId) -> Task<Result<()>> {
1327 self.metadata_cache.write().remove(id);
1328
1329 let db_connection = self.env.clone();
1330 let bodies = self.bodies;
1331 let metadata = self.metadata;
1332
1333 self.executor.spawn(async move {
1334 let mut txn = db_connection.write_txn()?;
1335
1336 metadata.delete(&mut txn, &id)?;
1337 bodies.delete(&mut txn, &id)?;
1338
1339 txn.commit()?;
1340 Ok(())
1341 })
1342 }
1343
1344 fn metadata(&self, id: PromptId) -> Option<PromptMetadata> {
1345 self.metadata_cache.read().metadata_by_id.get(&id).cloned()
1346 }
1347
1348 pub fn id_for_title(&self, title: &str) -> Option<PromptId> {
1349 let metadata_cache = self.metadata_cache.read();
1350 let metadata = metadata_cache
1351 .metadata
1352 .iter()
1353 .find(|metadata| metadata.title.as_ref().map(|title| &***title) == Some(title))?;
1354 Some(metadata.id)
1355 }
1356
1357 pub fn search(&self, query: String) -> Task<Vec<PromptMetadata>> {
1358 let cached_metadata = self.metadata_cache.read().metadata.clone();
1359 let executor = self.executor.clone();
1360 self.executor.spawn(async move {
1361 let mut matches = if query.is_empty() {
1362 cached_metadata
1363 } else {
1364 let candidates = cached_metadata
1365 .iter()
1366 .enumerate()
1367 .filter_map(|(ix, metadata)| {
1368 Some(StringMatchCandidate::new(
1369 ix,
1370 metadata.title.as_ref()?.to_string(),
1371 ))
1372 })
1373 .collect::<Vec<_>>();
1374 let matches = fuzzy::match_strings(
1375 &candidates,
1376 &query,
1377 false,
1378 100,
1379 &AtomicBool::default(),
1380 executor,
1381 )
1382 .await;
1383 matches
1384 .into_iter()
1385 .map(|mat| cached_metadata[mat.candidate_id].clone())
1386 .collect()
1387 };
1388 matches.sort_by_key(|metadata| Reverse(metadata.default));
1389 matches
1390 })
1391 }
1392
1393 fn save(
1394 &self,
1395 id: PromptId,
1396 title: Option<SharedString>,
1397 default: bool,
1398 body: Rope,
1399 ) -> Task<Result<()>> {
1400 if id.is_built_in() {
1401 return Task::ready(Err(anyhow!("built-in prompts cannot be saved")));
1402 }
1403
1404 let prompt_metadata = PromptMetadata {
1405 id,
1406 title,
1407 default,
1408 saved_at: Utc::now(),
1409 };
1410 self.metadata_cache.write().insert(prompt_metadata.clone());
1411
1412 let db_connection = self.env.clone();
1413 let bodies = self.bodies;
1414 let metadata = self.metadata;
1415
1416 self.executor.spawn(async move {
1417 let mut txn = db_connection.write_txn()?;
1418
1419 metadata.put(&mut txn, &id, &prompt_metadata)?;
1420 bodies.put(&mut txn, &id, &body.to_string())?;
1421
1422 txn.commit()?;
1423
1424 Ok(())
1425 })
1426 }
1427
1428 fn save_built_in_prompts(&self) -> Result<()> {
1429 self.save_built_in_prompt(
1430 PromptId::EditWorkflow,
1431 "Built-in: Editing Workflow",
1432 "prompts/edit_workflow.md",
1433 )?;
1434 Ok(())
1435 }
1436
1437 /// Write a built-in prompt to the database, preserving the value of the default field
1438 /// if a prompt with this id already exists. This method blocks.
1439 fn save_built_in_prompt(
1440 &self,
1441 id: PromptId,
1442 title: impl Into<SharedString>,
1443 body_path: &str,
1444 ) -> Result<()> {
1445 let mut metadata_cache = self.metadata_cache.write();
1446 let existing_metadata = metadata_cache.metadata_by_id.get(&id).cloned();
1447
1448 let prompt_metadata = PromptMetadata {
1449 id,
1450 title: Some(title.into()),
1451 default: existing_metadata.map_or(true, |m| m.default),
1452 saved_at: Utc::now(),
1453 };
1454
1455 metadata_cache.insert(prompt_metadata.clone());
1456
1457 let db_connection = self.env.clone();
1458 let bodies = self.bodies;
1459 let metadata_db = self.metadata;
1460
1461 let mut txn = db_connection.write_txn()?;
1462 metadata_db.put(&mut txn, &id, &prompt_metadata)?;
1463
1464 let body = String::from_utf8(Assets.load(body_path)?.unwrap().to_vec())?;
1465 bodies.put(&mut txn, &id, &body)?;
1466
1467 txn.commit()?;
1468 Ok(())
1469 }
1470
1471 fn save_metadata(
1472 &self,
1473 id: PromptId,
1474 mut title: Option<SharedString>,
1475 default: bool,
1476 ) -> Task<Result<()>> {
1477 let mut cache = self.metadata_cache.write();
1478
1479 if id.is_built_in() {
1480 title = cache
1481 .metadata_by_id
1482 .get(&id)
1483 .and_then(|metadata| metadata.title.clone());
1484 }
1485
1486 let prompt_metadata = PromptMetadata {
1487 id,
1488 title,
1489 default,
1490 saved_at: Utc::now(),
1491 };
1492
1493 cache.insert(prompt_metadata.clone());
1494
1495 let db_connection = self.env.clone();
1496 let metadata = self.metadata;
1497
1498 self.executor.spawn(async move {
1499 let mut txn = db_connection.write_txn()?;
1500 metadata.put(&mut txn, &id, &prompt_metadata)?;
1501 txn.commit()?;
1502
1503 Ok(())
1504 })
1505 }
1506
1507 fn first(&self) -> Option<PromptMetadata> {
1508 self.metadata_cache.read().metadata.first().cloned()
1509 }
1510
1511 pub fn step_resolution_prompt(&self) -> Result<String> {
1512 let path = "prompts/step_resolution.md";
1513
1514 Ok(String::from_utf8(
1515 Assets
1516 .load(path)?
1517 .ok_or_else(|| anyhow!("{path} not found"))?
1518 .to_vec(),
1519 )?)
1520 }
1521}
1522
1523/// Wraps a shared future to a prompt store so it can be assigned as a context global.
1524pub struct GlobalPromptStore(
1525 Shared<BoxFuture<'static, Result<Arc<PromptStore>, Arc<anyhow::Error>>>>,
1526);
1527
1528impl Global for GlobalPromptStore {}