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