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