diff --git a/apps/desktop/src/components/editor-area/index.tsx b/apps/desktop/src/components/editor-area/index.tsx index 76029b006..522db5f43 100644 --- a/apps/desktop/src/components/editor-area/index.tsx +++ b/apps/desktop/src/components/editor-area/index.tsx @@ -1,4 +1,4 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { type QueryClient, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import usePreviousValue from "beautiful-react-hooks/usePreviousValue"; import { diffWords } from "diff"; import { motion } from "motion/react"; @@ -8,6 +8,7 @@ import { z } from "zod"; import { useHypr } from "@/contexts"; import { extractTextFromHtml } from "@/utils/parse"; +import { autoTagGeneration } from "@/utils/tag-generation"; import { TemplateService } from "@/utils/template-service"; import { commands as analyticsCommands } from "@hypr/plugin-analytics"; import { commands as connectorCommands } from "@hypr/plugin-connector"; @@ -27,7 +28,12 @@ import { FloatingButton } from "./floating-button"; import { NoteHeader } from "./note-header"; import { TextSelectionPopover } from "./text-selection-popover"; -async function generateTitleDirect(enhancedContent: string, targetSessionId: string, sessions: Record) { +async function generateTitleDirect( + enhancedContent: string, + targetSessionId: string, + sessions: Record, + queryClient: QueryClient, +) { const [config, { type }, provider] = await Promise.all([ dbCommands.getConfig(), connectorCommands.getLlmConnection(), @@ -58,6 +64,36 @@ async function generateTitleDirect(enhancedContent: string, targetSessionId: str if (!session?.title && sessions[targetSessionId]?.getState) { const cleanedTitle = text.replace(/^["']|["']$/g, "").trim(); sessions[targetSessionId].getState().updateTitle(cleanedTitle); + + try { + const suggestedTags = await autoTagGeneration(targetSessionId); + + if (suggestedTags.length > 1) { + const allExistingTags = await dbCommands.listAllTags(); + const existingTagsMap = new Map( + allExistingTags.map(tag => [tag.name.toLowerCase(), tag]), + ); + + for (const tagName of suggestedTags.slice(0, 2)) { + try { + const existingTag = existingTagsMap.get(tagName.toLowerCase()); + + const tag = await dbCommands.upsertTag({ + id: existingTag?.id || crypto.randomUUID(), + name: tagName, + }); + + await dbCommands.assignTagToSession(tag.id, targetSessionId); + } catch (error) { + console.error(`Failed to assign tag "${tagName}":`, error); + } + } + + queryClient.invalidateQueries({ queryKey: ["session-tags", targetSessionId] }); + } + } catch (error) { + console.error("Failed to generate tags:", error); + } } } @@ -108,7 +144,7 @@ export default function EditorArea({ }); const sessionsStore = useSessions((s) => s.sessions); - + const queryClient = useQueryClient(); const { enhance, progress, isCancelled } = useEnhanceMutation({ sessionId, preMeetingNote, @@ -116,7 +152,7 @@ export default function EditorArea({ isLocalLlm: llmConnectionQuery.data?.type === "HyprLocal", onSuccess: (content) => { if (hasTranscriptWords) { - generateTitleDirect(content, sessionId, sessionsStore).catch(console.error); + generateTitleDirect(content, sessionId, sessionsStore, queryClient).catch(console.error); } }, }); diff --git a/apps/desktop/src/components/editor-area/note-header/chips/tag-chip.tsx b/apps/desktop/src/components/editor-area/note-header/chips/tag-chip.tsx index b52635296..f54a6933b 100644 --- a/apps/desktop/src/components/editor-area/note-header/chips/tag-chip.tsx +++ b/apps/desktop/src/components/editor-area/note-header/chips/tag-chip.tsx @@ -41,15 +41,11 @@ export function TagChip({ sessionId, hashtags = [], isVeryNarrow = false, isNarr return isVeryNarrow ? "Tags" : isNarrow ? "Tags" : "Add tags"; } - if (isVeryNarrow) { + // show just the number when narrow (matching participants-chip) + if (isVeryNarrow || isNarrow) { return totalTags.toString(); } - if (isNarrow && firstTag && firstTag.length > 8) { - const truncated = firstTag.slice(0, 6) + "..."; - return additionalTags > 0 ? `${truncated} +${additionalTags}` : truncated; - } - return additionalTags > 0 ? `${firstTag} +${additionalTags}` : firstTag; }; diff --git a/apps/desktop/src/components/right-panel/views/transcript-view.tsx b/apps/desktop/src/components/right-panel/views/transcript-view.tsx index 87b75439c..ca654e1e6 100644 --- a/apps/desktop/src/components/right-panel/views/transcript-view.tsx +++ b/apps/desktop/src/components/right-panel/views/transcript-view.tsx @@ -148,7 +148,11 @@ export function TranscriptView() { /> ) : ( -
+
{!showEmptyMessage && (

Transcript

diff --git a/apps/desktop/src/utils/tag-generation.ts b/apps/desktop/src/utils/tag-generation.ts index 623af3338..40759ca8b 100644 --- a/apps/desktop/src/utils/tag-generation.ts +++ b/apps/desktop/src/utils/tag-generation.ts @@ -30,11 +30,84 @@ export async function generateTagsForSession(sessionId: string): Promise t.name), + historical_tags: historicalTags.slice(0, 20).map(t => t.name), + }, + ); + + const provider = await modelProvider(); + const model = provider.languageModel("defaultModel"); + + const result = await generateText({ + model, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: userPrompt }, + ], + providerOptions: { + [localProviderName]: { + metadata: { + grammar: { + task: "tags", + } satisfies Grammar, + }, + }, + }, + }); + + const schema = z.preprocess( + (val) => (typeof val === "string" ? JSON.parse(val) : val), + z.array(z.string().min(1)).min(1).max(5), + ); + + const parsed = schema.safeParse(result.text); + return parsed.success ? parsed.data : []; + } catch (error) { + console.error("Tag generation failed:", error); + return []; + } +} + +export async function autoTagGeneration(sessionId: string): Promise { + try { + const { type: connectionType } = await connectorCommands.getLlmConnection(); + + const config = await dbCommands.getConfig(); + const session = await dbCommands.getSession({ id: sessionId }); + if (!session) { + throw new Error("Session not found"); + } + + const historicalTags = await dbCommands.listAllTags(); + const currentTags = await dbCommands.listSessionTags(sessionId); + + const extractHashtags = (text: string): string[] => { + const hashtagRegex = /#(\w+)/g; + return Array.from(text.matchAll(hashtagRegex), match => match[1]); + }; + + const existingHashtags = extractHashtags(session.raw_memo_html); + + const systemPrompt = await templateCommands.render( + "auto_generate_tags.system", + { config, type: connectionType }, + ); + + let contentToUse = session.enhanced_memo_html ?? session.raw_memo_html; + + const userPrompt = await templateCommands.render( + "auto_generate_tags.user", + { + title: session.title, + content: contentToUse, existing_hashtags: existingHashtags, formal_tags: currentTags.map(t => t.name), historical_tags: historicalTags.slice(0, 20).map(t => t.name), diff --git a/crates/template/assets/auto_generate_tags.system.jinja b/crates/template/assets/auto_generate_tags.system.jinja new file mode 100644 index 000000000..790f2b106 --- /dev/null +++ b/crates/template/assets/auto_generate_tags.system.jinja @@ -0,0 +1,23 @@ +You are an intelligent tagging assistant. +Your job is to analyze note content and existing tags to suggest relevant, useful tags based on the content and the user's historical tagging patterns. + +## Guidelines: + +1. Suggest minimum 3, maximum 5 specific, actionable tags +2. Always look first at existing tags (which will be named 'historical_tags' in the user prompt) and if there are any that are relevant to the content, use them. You must return the exact same tags - don't change them if you are reusing them. +3. Also, don't return tags that are already in the content. + - Two types of tags that are not relevant to the content: + - Hashtags (which will be named 'existing_hashtags' in the user prompt) + - Formal tags (which will be named 'formal_tags' in the user prompt) +4. Only suggest new tags if you can't find any relevant existing tags. + - Before coming up with your creative ideas, consider choosing from these default options: + ["Sales", "User Interview", "Product", "Marketing", "Engineering", "Customer Support", "Research", "Insight", "Design", "Recruiting"] +5. You can return a combination of existing tags and new tags. +6. Avoid generic tags like "note" or "content" +7. Make tags specific enough to be useful for search and organization +8. Consider meeting types, project names, people, tools, concepts, and workflows + +## Response Format: + +Return only a JSON array of suggested tag names, nothing else. +Example: ["project-alpha", "team-meeting", "quarterly-planning", "action-items"] diff --git a/crates/template/assets/auto_generate_tags.user.jinja b/crates/template/assets/auto_generate_tags.user.jinja new file mode 100644 index 000000000..9eadf9c2d --- /dev/null +++ b/crates/template/assets/auto_generate_tags.user.jinja @@ -0,0 +1,29 @@ +## Note Content: + +**Title:** {{ title }} + +**Content:** +{{ content }} + +{% if existing_hashtags and existing_hashtags|length > 0 %} + +## Current Hashtags in Content: + +{% for hashtag in existing_hashtags %}#{{ hashtag }}{% if not loop.last %}, {% endif %}{% endfor %} +{% endif %} + +{% if formal_tags and formal_tags|length > 0 %} + +## Current Formal Tags: + +{% for tag in formal_tags %}{{ tag }}{% if not loop.last %}, {% endif %}{% endfor %} +{% endif %} + +{% if historical_tags and historical_tags|length > 0 %} + +## User's Historical Tags (existing tags in the entire database): + +{% for tag in historical_tags %}{{ tag }}{% if not loop.last %}, {% endif %}{% endfor %} +{% endif %} + +Based on this note content and the user's tagging patterns, suggest 3-5 relevant tags that would help organize and categorize this note effectively. Respond only with a JSON array of strings, e.g. ["tag1", "tag2"]. diff --git a/crates/template/src/lib.rs b/crates/template/src/lib.rs index f55149179..b4f10173a 100644 --- a/crates/template/src/lib.rs +++ b/crates/template/src/lib.rs @@ -44,6 +44,10 @@ pub enum PredefinedTemplate { SuggestTagsUser, #[strum(serialize = "ai_chat.system")] AiChatSystem, + #[strum(serialize = "auto_generate_tags.system")] + AutoGenerateTagsSystem, + #[strum(serialize = "auto_generate_tags.user")] + AutoGenerateTagsUser, } impl From for Template { @@ -66,6 +70,12 @@ impl From for Template { Template::Static(PredefinedTemplate::SuggestTagsUser) } PredefinedTemplate::AiChatSystem => Template::Static(PredefinedTemplate::AiChatSystem), + PredefinedTemplate::AutoGenerateTagsSystem => { + Template::Static(PredefinedTemplate::AutoGenerateTagsSystem) + } + PredefinedTemplate::AutoGenerateTagsUser => { + Template::Static(PredefinedTemplate::AutoGenerateTagsUser) + } } } } @@ -77,6 +87,10 @@ pub const CREATE_TITLE_USER_TPL: &str = include_str!("../assets/create_title.use pub const SUGGEST_TAGS_SYSTEM_TPL: &str = include_str!("../assets/suggest_tags.system.jinja"); pub const SUGGEST_TAGS_USER_TPL: &str = include_str!("../assets/suggest_tags.user.jinja"); pub const AI_CHAT_SYSTEM_TPL: &str = include_str!("../assets/ai_chat_system.jinja"); +pub const AUTO_GENERATE_TAGS_SYSTEM_TPL: &str = + include_str!("../assets/auto_generate_tags.system.jinja"); +pub const AUTO_GENERATE_TAGS_USER_TPL: &str = + include_str!("../assets/auto_generate_tags.user.jinja"); pub fn init(env: &mut minijinja::Environment) { env.set_unknown_method_callback(minijinja_contrib::pycompat::unknown_method_callback); @@ -113,7 +127,16 @@ pub fn init(env: &mut minijinja::Environment) { AI_CHAT_SYSTEM_TPL, ) .unwrap(); - + env.add_template( + PredefinedTemplate::AutoGenerateTagsSystem.as_ref(), + AUTO_GENERATE_TAGS_SYSTEM_TPL, + ) + .unwrap(); + env.add_template( + PredefinedTemplate::AutoGenerateTagsUser.as_ref(), + AUTO_GENERATE_TAGS_USER_TPL, + ) + .unwrap(); env.add_filter("timeline", filters::timeline); env.add_filter("language", filters::language);