diff --git a/apps/desktop/src/components/editor-area/index.tsx b/apps/desktop/src/components/editor-area/index.tsx index fa3d96deb..6476039a4 100644 --- a/apps/desktop/src/components/editor-area/index.tsx +++ b/apps/desktop/src/components/editor-area/index.tsx @@ -15,7 +15,7 @@ import Editor, { type TiptapEditor } from "@hypr/tiptap/editor"; import Renderer from "@hypr/tiptap/renderer"; import { extractHashtags } from "@hypr/tiptap/shared"; import { cn } from "@hypr/ui/lib/utils"; -import { markdownTransform, modelProvider, smoothStream, streamText } from "@hypr/utils/ai"; +import { generateText, markdownTransform, modelProvider, providerName, smoothStream, streamText } from "@hypr/utils/ai"; import { useOngoingSession, useSession } from "@hypr/utils/contexts"; import { enhanceFailedToast } from "../toast/shared"; import { FloatingButton } from "./floating-button"; @@ -52,9 +52,14 @@ export default function EditorArea({ [sessionId, showRaw], ); + const generateTitle = useGenerateTitleMutation({ sessionId }); const enhance = useEnhanceMutation({ sessionId, rawContent, + onSuccess: (content) => { + console.log("useEnhanceMutation onSuccess", content); + generateTitle.mutate({ enhancedContent: content }); + }, }); useAutoEnhance({ @@ -165,9 +170,11 @@ export default function EditorArea({ export function useEnhanceMutation({ sessionId, rawContent, + onSuccess, }: { sessionId: string; rawContent: string; + onSuccess: (enhancedContent: string) => void; }) { const { userId, onboardingSessionId } = useHypr(); @@ -246,6 +253,13 @@ export function useEnhanceMutation({ markdownTransform(), smoothStream({ delayInMs: 80, chunking: "line" }), ], + providerOptions: { + [providerName]: { + metadata: { + grammar: "enhance", + }, + }, + }, }); let acc = ""; @@ -257,7 +271,9 @@ export function useEnhanceMutation({ return text.then(miscCommands.opinionatedMdToHtml); }, - onSuccess: () => { + onSuccess: (enhancedContent) => { + onSuccess(enhancedContent ?? ""); + analyticsCommands.event({ event: sessionId === onboardingSessionId ? "onboarding_enhance_done" @@ -280,7 +296,63 @@ export function useEnhanceMutation({ return enhance; } -export function useAutoEnhance({ +function useGenerateTitleMutation({ sessionId }: { sessionId: string }) { + const { title, updateTitle } = useSession(sessionId, (s) => ({ + title: s.session.title, + updateTitle: s.updateTitle, + })); + + const generateTitle = useMutation({ + mutationKey: ["generateTitle", sessionId], + mutationFn: async ({ enhancedContent }: { enhancedContent: string }) => { + const config = await dbCommands.getConfig(); + const { type } = await connectorCommands.getLlmConnection(); + + const systemMessage = await templateCommands.render( + "create_title.system", + { config, type }, + ); + + const userMessage = await templateCommands.render( + "create_title.user", + { + type, + enhanced_note: enhancedContent, + }, + ); + + const abortController = new AbortController(); + const abortSignal = AbortSignal.any([abortController.signal, AbortSignal.timeout(30 * 1000)]); + + const provider = await modelProvider(); + const model = provider.languageModel("defaultModel"); + + const newTitle = await generateText({ + abortSignal, + model, + messages: [ + { role: "system", content: systemMessage }, + { role: "user", content: userMessage }, + ], + providerOptions: { + [providerName]: { + metadata: { + grammar: "title", + }, + }, + }, + }); + + if (!title) { + updateTitle(newTitle.text); + } + }, + }); + + return generateTitle; +} + +function useAutoEnhance({ sessionId, enhanceStatus, enhanceMutate, diff --git a/apps/desktop/src/components/editor-area/note-header/index.tsx b/apps/desktop/src/components/editor-area/note-header/index.tsx index 614a0a65d..2c89724fb 100644 --- a/apps/desktop/src/components/editor-area/note-header/index.tsx +++ b/apps/desktop/src/components/editor-area/note-header/index.tsx @@ -14,7 +14,9 @@ interface NoteHeaderProps { hashtags?: string[]; } -export function NoteHeader({ onNavigateToEditor, editable, sessionId, hashtags = [] }: NoteHeaderProps) { +export function NoteHeader( + { onNavigateToEditor, editable, sessionId, hashtags = [] }: NoteHeaderProps, +) { const updateTitle = useSession(sessionId, (s) => s.updateTitle); const sessionTitle = useSession(sessionId, (s) => s.session.title); diff --git a/apps/desktop/src/locales/en/messages.po b/apps/desktop/src/locales/en/messages.po index a3a6c0970..53cf80639 100644 --- a/apps/desktop/src/locales/en/messages.po +++ b/apps/desktop/src/locales/en/messages.po @@ -1072,7 +1072,7 @@ msgstr "Type to search..." msgid "Ugh! Can't use it!" msgstr "Ugh! Can't use it!" -#: src/components/editor-area/note-header/title-input.tsx:33 +#: src/components/editor-area/note-header/title-input.tsx:39 msgid "Untitled" msgstr "Untitled" diff --git a/apps/desktop/src/locales/ko/messages.po b/apps/desktop/src/locales/ko/messages.po index 09cc6a5ef..142cb226f 100644 --- a/apps/desktop/src/locales/ko/messages.po +++ b/apps/desktop/src/locales/ko/messages.po @@ -1072,7 +1072,7 @@ msgstr "" msgid "Ugh! Can't use it!" msgstr "" -#: src/components/editor-area/note-header/title-input.tsx:33 +#: src/components/editor-area/note-header/title-input.tsx:39 msgid "Untitled" msgstr "" diff --git a/crates/gbnf/assets/title.gbnf b/crates/gbnf/assets/title.gbnf new file mode 100644 index 000000000..4c9ece904 --- /dev/null +++ b/crates/gbnf/assets/title.gbnf @@ -0,0 +1,3 @@ +char ::= [A-Za-z0-9] +start ::= [A-Z0-9] +root ::= start char* (" " char+)* diff --git a/crates/gbnf/src/lib.rs b/crates/gbnf/src/lib.rs index e81b5537e..68bb72f6d 100644 --- a/crates/gbnf/src/lib.rs +++ b/crates/gbnf/src/lib.rs @@ -1,7 +1,10 @@ pub const ENHANCE_AUTO: &str = include_str!("../assets/enhance-auto.gbnf"); pub const ENHANCE_TEMPLATE: &str = include_str!("../assets/enhance-template.gbnf"); +pub const TITLE: &str = include_str!("../assets/title.gbnf"); + pub enum GBNF { Enhance(Option>), + Title, } impl GBNF { @@ -9,16 +12,37 @@ impl GBNF { match self { GBNF::Enhance(Some(_)) => ENHANCE_TEMPLATE.to_string(), GBNF::Enhance(None) => ENHANCE_AUTO.to_string(), + GBNF::Title => TITLE.to_string(), } } } #[cfg(test)] mod tests { + use super::*; use indoc::indoc; #[test] - fn test_1() { + fn test_title_grammar() { + let gbnf = gbnf_validator::Validator::new().unwrap(); + + for (input, expected) in vec![ + ("Meeting Summary", true), + ("Product Review Discussion", true), + ("A", true), + ("Planning Session", true), + ("Q1 Planning Session", true), + ("meeting summary", false), + ("Meeting-Summary", false), + ("", false), + ] { + let result = gbnf.validate(TITLE, input).unwrap(); + assert_eq!(result, expected, "failed: {}", input); + } + } + + #[test] + fn test_enhance_grammar() { let input_1 = "\n- Objective\n- Key Takeaways\n- Importance of Complementary Skills\n- Benefits of Using Online Resources\n- Advice for Undergrad Students\n# Objective\n\n- **Search is the Best Way to Find Answers**: The speaker emphasizes the importance of utilizing online resources like Google to find answers to questions.\n- **Value in Complementary Skills**: The speaker highlights the need to acquire complementary skills to traditional research methods.\n\n# Key Takeaways\n\n- **Complementary skills include both traditional research and online resource utilization**: The speaker suggests that skills like using a blank sheet of paper with no Internet and effective Google searching are essential.\n- **Online resources can help find pre-solved problems**: The speaker advises investing time in finding existing resources and communities that have already solved problems.\n\n# Importance of Complementary Skills\n\n- **Traditional research is just the starting point**: The speaker suggests that traditional research methods are just the beginning and should be complemented with other skills.\n- **Effective use of online resources can save time and effort**: The speaker highlights the benefits of utilizing online resources in research and problem-solving.\n\n# Benefits of Using Online Resources\n\n- **Access to knowledge from experts and communities**: The speaker suggests that online resources provide access to knowledge and expertise from experienced individuals.\n- **Time-saving and efficient**: The speaker emphasizes the benefits of finding pre-solved problems through online resources.\n\n# Advice for Undergrad Students\n\n- **Start by searching online**: The speaker advises undergrad students to start by searching online for answers to questions and exploring different resources.\n- **Be open to finding existing solutions**: The speaker emphasizes the importance of being open to finding pre-solved problems and leveraging existing resources.\n\n"; let input_2 = indoc! {" diff --git a/crates/template/assets/create_title.system.jinja b/crates/template/assets/create_title.system.jinja index 965d61da3..880fac732 100644 --- a/crates/template/assets/create_title.system.jinja +++ b/crates/template/assets/create_title.system.jinja @@ -1,24 +1,2 @@ -You are a professional assistant that generates a refined title for a meeting note in {{ config.general.display_language | language }}. - -# Inputs Provided by the user: - -- Meeting Information (txt) -- Raw Note (markdown) -- Meeting Transcript (txt) - -# Your Task: - -1. Analyze the provided content and transcript thoroughly. -2. Create a more precise, informative, and engaging title that accurately reflects the main topics and outcomes of the meeting. -3. Ensure the new title is concise yet comprehensive, ideally not exceeding 10 words. -4. If the original title is already optimal, you may suggest keeping it as is. - -Respond with only the refined title, without any additional explanation or formatting. Respond in JSON. - -# Output Structure: - -```json -{ - "title": string -} -``` +You are a professional assistant that generates a perfect title for a meeting note in {{ config.general.display_language | language }}. +Only output title, nothing else. diff --git a/crates/template/assets/create_title.user.jinja b/crates/template/assets/create_title.user.jinja index 307655b1f..887c08821 100644 --- a/crates/template/assets/create_title.user.jinja +++ b/crates/template/assets/create_title.user.jinja @@ -1,23 +1,5 @@ -# Meeting Information: + +{{ enhanced_note }} + - -{% if event %}Event: {{ event.name }}{% endif %} -{% if participants | length > 0 %}Participants:{% endif %} -{%- for participant in participants %} - -- {{ participant.name }}{% if participant.name == config_profile.full_name %} (this is me){% endif %} - {%- endfor %} - - -# Raw Note: - - -{{ pre_meeting_editor }} -{{ in_meeting_editor }} - - -# Meeting Transcript: - - -{{ render_timeline_view(timeline_view=timeline_view) }} - +Now, give me SUPER CONCISE title for above note. Only about the topic of the meeting. diff --git a/crates/template/src/lib.rs b/crates/template/src/lib.rs index af30e7dcd..2a6623c28 100644 --- a/crates/template/src/lib.rs +++ b/crates/template/src/lib.rs @@ -34,6 +34,10 @@ pub enum PredefinedTemplate { EnhanceSystem, #[strum(serialize = "enhance.user")] EnhanceUser, + #[strum(serialize = "create_title.system")] + CreateTitleSystem, + #[strum(serialize = "create_title.user")] + CreateTitleUser, } impl From for Template { @@ -43,12 +47,20 @@ impl From for Template { Template::Static(PredefinedTemplate::EnhanceSystem) } PredefinedTemplate::EnhanceUser => Template::Static(PredefinedTemplate::EnhanceUser), + PredefinedTemplate::CreateTitleSystem => { + Template::Static(PredefinedTemplate::CreateTitleSystem) + } + PredefinedTemplate::CreateTitleUser => { + Template::Static(PredefinedTemplate::CreateTitleUser) + } } } } pub const ENHANCE_SYSTEM_TPL: &str = include_str!("../assets/enhance.system.jinja"); pub const ENHANCE_USER_TPL: &str = include_str!("../assets/enhance.user.jinja"); +pub const CREATE_TITLE_SYSTEM_TPL: &str = include_str!("../assets/create_title.system.jinja"); +pub const CREATE_TITLE_USER_TPL: &str = include_str!("../assets/create_title.user.jinja"); pub fn init(env: &mut minijinja::Environment) { env.set_unknown_method_callback(minijinja_contrib::pycompat::unknown_method_callback); @@ -60,6 +72,16 @@ pub fn init(env: &mut minijinja::Environment) { .unwrap(); env.add_template(PredefinedTemplate::EnhanceUser.as_ref(), ENHANCE_USER_TPL) .unwrap(); + env.add_template( + PredefinedTemplate::CreateTitleSystem.as_ref(), + CREATE_TITLE_SYSTEM_TPL, + ) + .unwrap(); + env.add_template( + PredefinedTemplate::CreateTitleUser.as_ref(), + CREATE_TITLE_USER_TPL, + ) + .unwrap(); env.add_filter("timeline", filters::timeline); env.add_filter("language", filters::language); diff --git a/packages/utils/src/ai.ts b/packages/utils/src/ai.ts index 9cc721cc9..1c532eb04 100644 --- a/packages/utils/src/ai.ts +++ b/packages/utils/src/ai.ts @@ -15,12 +15,14 @@ export const useChat = (options: Parameters[0]) => { }); }; +export const providerName = "hypr-llm"; + const getModel = async ({ onboarding }: { onboarding: boolean }) => { const getter = onboarding ? connectorCommands.getLocalLlmConnection : connectorCommands.getLlmConnection; const { type, connection: { api_base, api_key } } = await getter(); const openai = createOpenAICompatible({ - name: "hypr-llm", + name: providerName, baseURL: api_base, apiKey: api_key ?? "SOMETHING_NON_EMPTY", fetch: customFetch, diff --git a/plugins/local-llm/src/lib.rs b/plugins/local-llm/src/lib.rs index c211f16f0..92c95bea0 100644 --- a/plugins/local-llm/src/lib.rs +++ b/plugins/local-llm/src/lib.rs @@ -71,9 +71,9 @@ mod test { use super::*; use async_openai::types::{ - ChatCompletionRequestMessage, ChatCompletionRequestUserMessageArgs, - CreateChatCompletionRequest, CreateChatCompletionResponse, - CreateChatCompletionStreamResponse, + ChatCompletionRequestMessage, ChatCompletionRequestSystemMessageArgs, + ChatCompletionRequestUserMessageArgs, CreateChatCompletionRequest, + CreateChatCompletionResponse, CreateChatCompletionStreamResponse, }; use futures_util::StreamExt; @@ -109,6 +109,28 @@ mod test { } } + fn title_generation_request() -> CreateChatCompletionRequest { + CreateChatCompletionRequest { + messages: vec![ + ChatCompletionRequestMessage::System( + ChatCompletionRequestSystemMessageArgs::default() + .content("You are a professional assistant that generates a refined title for a meeting note in English.") + .build() + .unwrap() + .into(), + ), + ChatCompletionRequestMessage::User( + ChatCompletionRequestUserMessageArgs::default() + .content("# Enhanced Meeting Note:\n\n\n# Project Planning\n- Discussed Q1 roadmap\n- Reviewed budget allocations\n- Set team responsibilities\n") + .build() + .unwrap() + .into(), + ), + ], + ..Default::default() + } + } + #[tokio::test] #[ignore] // cargo test test_non_streaming_response -p tauri-plugin-local-llm -- --ignored --nocapture @@ -181,4 +203,91 @@ mod test { assert!(content.contains("Seoul")); } + + #[tokio::test] + #[ignore] + // cargo test test_title_generation_non_streaming -p tauri-plugin-local-llm -- --ignored --nocapture + async fn test_title_generation_non_streaming() { + let app = create_app(tauri::test::mock_builder()); + app.start_server().await.unwrap(); + let api_base = app.api_base().await.unwrap(); + + let client = reqwest::Client::new(); + + let response = client + .post(format!("{}/chat/completions", api_base)) + .json(&CreateChatCompletionRequest { + stream: Some(false), + ..title_generation_request() + }) + .send() + .await + .unwrap(); + + let data = response + .json::() + .await + .unwrap(); + + let content = data.choices[0].message.content.clone().unwrap(); + println!("Generated title: {}", content); + + // Title should start with capital letter and contain only letters/spaces + assert!(!content.is_empty()); + assert!(content.chars().next().unwrap().is_uppercase()); + assert!(content + .chars() + .all(|c| c.is_alphabetic() || c.is_whitespace())); + } + + #[tokio::test] + #[ignore] + // cargo test test_title_generation_streaming -p tauri-plugin-local-llm -- --ignored --nocapture + async fn test_title_generation_streaming() { + let app = create_app(tauri::test::mock_builder()); + app.start_server().await.unwrap(); + let api_base = app.api_base().await.unwrap(); + + let client = reqwest::Client::new(); + + let response = client + .post(format!("{}/chat/completions", api_base)) + .json(&CreateChatCompletionRequest { + stream: Some(true), + ..title_generation_request() + }) + .send() + .await + .unwrap(); + + let stream = response.bytes_stream().map(|chunk| { + chunk.map(|data| { + let text = String::from_utf8_lossy(&data); + let stripped = text.split("data: ").collect::>()[1]; + let c: CreateChatCompletionStreamResponse = serde_json::from_str(stripped).unwrap(); + c.choices + .first() + .unwrap() + .delta + .content + .as_ref() + .unwrap() + .clone() + }) + }); + + let content = stream + .filter_map(|r| async move { r.ok() }) + .collect::() + .await; + + println!("Generated title (streaming): {}", content); + + // Title should start with capital letter and contain only letters/spaces + assert!(!content.is_empty()); + assert!(content.chars().next().unwrap().is_uppercase()); + assert!(content + .chars() + .all(|c| c.is_alphabetic() || c.is_whitespace())); + } } diff --git a/plugins/local-llm/src/server.rs b/plugins/local-llm/src/server.rs index 631d5742c..6e713e1a1 100644 --- a/plugins/local-llm/src/server.rs +++ b/plugins/local-llm/src/server.rs @@ -223,12 +223,21 @@ fn build_response( .map(hypr_llama::FromOpenAI::from_openai) .collect(); - let request = hypr_llama::LlamaRequest { - messages, - // TODO: should not hard-code this - grammar: Some(hypr_gbnf::GBNF::Enhance(Some(vec!["".to_string()])).build()), + let grammar = if request + .metadata + .as_ref() + .unwrap_or(&serde_json::Value::Object(Default::default())) + .get("grammar") + .and_then(|v| v.as_str()) + == Some("title") + { + Some(hypr_gbnf::GBNF::Title.build()) + } else { + Some(hypr_gbnf::GBNF::Enhance(Some(vec!["".to_string()])).build()) }; + let request = hypr_llama::LlamaRequest { messages, grammar }; + Ok(Box::pin(model.generate_stream(request)?)) }