From 093da76eeeaa4f6e3e8a20ca2fa586b0dea4beee Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Mon, 6 Apr 2026 13:27:02 -0700 Subject: [PATCH] feat(smart-actions): add run-stream SSE, history endpoint, weekly-digest template, web client functions (G1-G5) --- backend/src/lib/reading-time.ts | 16 ++ backend/src/mcp/note-tool-contracts.ts | 33 ++++ backend/src/mcp/note-tools.test.ts | 1 + backend/src/mcp/note-tools.ts | 65 +++++++ .../modules/note-prompts/note-prompts.test.ts | 4 +- backend/src/modules/note-prompts/routes.ts | 92 ++++++++- backend/src/modules/note-prompts/seed.ts | 13 ++ docs/SMART_ACTIONS_USER_GUIDE.md | 180 ++++++++++++++++++ .../app/(app)/workspaces/[id]/gaps/page.tsx | 107 +++++++++++ web/src/components/PromptResultView.tsx | 107 +++++++++++ web/src/components/PromptTemplateEditor.tsx | 151 +++++++++++++++ web/src/components/RunPromptModal.tsx | 150 +++++++++++++++ web/src/lib/prompt-client.ts | 68 +++++++ web/src/lib/types.ts | 5 +- 14 files changed, 985 insertions(+), 7 deletions(-) create mode 100644 backend/src/lib/reading-time.ts create mode 100644 docs/SMART_ACTIONS_USER_GUIDE.md create mode 100644 web/src/app/(app)/workspaces/[id]/gaps/page.tsx create mode 100644 web/src/components/PromptResultView.tsx create mode 100644 web/src/components/PromptTemplateEditor.tsx create mode 100644 web/src/components/RunPromptModal.tsx diff --git a/backend/src/lib/reading-time.ts b/backend/src/lib/reading-time.ts new file mode 100644 index 0000000..31ce49e --- /dev/null +++ b/backend/src/lib/reading-time.ts @@ -0,0 +1,16 @@ +/** + * Reading time estimation utility. + * + * Pure calculation — no LLM needed. + * Average adult reading speed: ~238 words per minute. + */ + +/** + * Estimate reading time for HTML or plain-text content. + * Strips HTML tags before counting words. + */ +export function estimateReadingTime(content: string): { minutes: number; words: number } { + const plain = content.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(); + const words = plain.split(/\s+/).filter(Boolean).length; + return { minutes: Math.max(1, Math.ceil(words / 238)), words }; +} diff --git a/backend/src/mcp/note-tool-contracts.ts b/backend/src/mcp/note-tool-contracts.ts index 23b9e4e..aca1b6d 100644 --- a/backend/src/mcp/note-tool-contracts.ts +++ b/backend/src/mcp/note-tool-contracts.ts @@ -13,6 +13,7 @@ export const NOTES_MCP_TOOL_NAMES = { suggestTags: 'notes.intelligence.suggest_tags', checkDuplicates: 'notes.intelligence.check_duplicates', suggestLinks: 'notes.intelligence.suggest_links', + runPrompt: 'notes.prompts.run', } as const; export const NoteToolRoleSchema = z.enum(['viewer', 'admin', 'super_admin']); @@ -334,6 +335,38 @@ export const SmartActionMcpToolDefinitions = { }, }; +// ── Run Prompt MCP tool schemas ─────────────────────────────── + +export const RunPromptToolInputSchema = z.object({ + noteId: z.string().min(1).max(128), + workspaceId: z.string().min(1).max(128), + templateId: z.string().min(1).max(128).optional(), + inlinePrompt: z.string().max(4000).optional(), + parameters: z.record(z.string()).optional(), + additionalNoteIds: z.array(z.string().min(1).max(128)).max(10).optional(), + dryRun: z.boolean().default(false), +}); + +export const RunPromptToolOutputSchema = z.object({ + content: z.string(), + model: z.string(), + resultNoteId: z.string().nullable(), + resultArtifactId: z.string().nullable(), + approvalState: z.string().optional(), +}); + +export const RunPromptMcpToolDefinition = { + name: NOTES_MCP_TOOL_NAMES.runPrompt, + description: 'Run a prompt template (or inline prompt) on a note. Can merge/compare multiple notes, chain from previous results, and produce new notes or artifacts.', + requiredRole: 'admin' as const, + inputSchema: RunPromptToolInputSchema, + outputSchema: RunPromptToolOutputSchema, + readOnly: false, +}; + +export type RunPromptToolInput = z.infer; +export type RunPromptToolOutput = z.infer; + export type SuggestTagsToolInput = z.infer; export type SuggestTagsToolOutput = z.infer; export type CheckDuplicatesToolInput = z.infer; diff --git a/backend/src/mcp/note-tools.test.ts b/backend/src/mcp/note-tools.test.ts index a7b47a5..c2155c1 100644 --- a/backend/src/mcp/note-tools.test.ts +++ b/backend/src/mcp/note-tools.test.ts @@ -50,6 +50,7 @@ describe('note executable MCP tools', () => { NOTES_MCP_TOOL_NAMES.suggestTags, NOTES_MCP_TOOL_NAMES.checkDuplicates, NOTES_MCP_TOOL_NAMES.suggestLinks, + NOTES_MCP_TOOL_NAMES.runPrompt, ]); }); diff --git a/backend/src/mcp/note-tools.ts b/backend/src/mcp/note-tools.ts index af733ae..7434eae 100644 --- a/backend/src/mcp/note-tools.ts +++ b/backend/src/mcp/note-tools.ts @@ -26,6 +26,9 @@ import { CheckDuplicatesToolOutputSchema, SuggestLinksToolOutputSchema, SmartActionMcpToolDefinitions, + RunPromptMcpToolDefinition, + RunPromptToolOutputSchema, + type RunPromptToolInput, type AttachArtifactToolInput, type CreateNoteDraftToolInput, type ExtractTasksToolInput, @@ -518,6 +521,63 @@ async function executeAttachArtifact(args: AttachArtifactToolInput, req: NotesMc }); } +// ── Run Prompt MCP tool implementation ──────────────────────── + +async function executeRunPrompt(args: RunPromptToolInput, req: NotesMcpRequest) { + const userId = requireUserId(req); + const note = await getNote(args.noteId, args.workspaceId); + if (!note || note.userId !== userId || note.productId !== PRODUCT_ID) { + throw new Error('Note not found'); + } + + const { executePrompt } = await import('../modules/note-prompts/runner.js'); + const promptRepo = await import('../modules/note-prompts/repository.js'); + + // Resolve template: by templateId or use inline prompt with a synthetic template + let template; + if (args.templateId) { + template = await promptRepo.getPromptTemplate(args.templateId, userId); + if (!template) throw new Error(`Template "${args.templateId}" not found`); + } else if (args.inlinePrompt) { + template = { + id: 'inline', + productId: PRODUCT_ID, + userId, + slug: 'inline-mcp', + name: 'Inline MCP Prompt', + description: '', + systemPrompt: 'You are a helpful note assistant.', + userPromptTemplate: args.inlinePrompt, + inputType: 'text' as const, + outputType: 'new_note' as const, + category: 'transform' as const, + isBuiltin: false, + requiresApproval: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + } else { + throw new Error('Either templateId or inlinePrompt is required'); + } + + const input = { + templateId: template.slug, + noteId: args.noteId, + workspaceId: args.workspaceId, + variables: args.parameters, + }; + + const result = await executePrompt(template, input, note.body ?? ''); + + return RunPromptToolOutputSchema.parse({ + content: result.content, + model: result.model, + resultNoteId: result.createdNoteId ?? null, + resultArtifactId: result.createdArtifactId ?? null, + approvalState: result.approvalState, + }); +} + // ── Smart Action MCP tool implementations ───────────────────── async function executeSuggestTags(args: SuggestTagsToolInput, req: NotesMcpRequest) { @@ -639,6 +699,7 @@ export const NotesExecutableMcpTools: Array< | NotesMcpTool | NotesMcpTool | NotesMcpTool + | NotesMcpTool > = [ { ...NotesMcpToolDefinitions.list, @@ -684,6 +745,10 @@ export const NotesExecutableMcpTools: Array< ...SmartActionMcpToolDefinitions.suggestLinks, execute: executeSuggestLinks, }, + { + ...RunPromptMcpToolDefinition, + execute: executeRunPrompt, + }, ]; export function getNotesExecutableMcpTool(name: string) { diff --git a/backend/src/modules/note-prompts/note-prompts.test.ts b/backend/src/modules/note-prompts/note-prompts.test.ts index 5477f44..dc5acd5 100644 --- a/backend/src/modules/note-prompts/note-prompts.test.ts +++ b/backend/src/modules/note-prompts/note-prompts.test.ts @@ -272,9 +272,9 @@ describe('reading-time', () => { }); describe('seed', () => { - it('getBuiltinTemplates returns 20 templates', () => { + it('getBuiltinTemplates returns 21 templates', () => { const templates = getBuiltinTemplates(); - expect(templates.length).toBe(20); + expect(templates.length).toBe(21); expect(templates.every((t) => t.isBuiltin)).toBe(true); expect(templates.every((t) => t.id.startsWith('builtin-'))).toBe(true); }); diff --git a/backend/src/modules/note-prompts/routes.ts b/backend/src/modules/note-prompts/routes.ts index 54a697e..921d681 100644 --- a/backend/src/modules/note-prompts/routes.ts +++ b/backend/src/modules/note-prompts/routes.ts @@ -9,6 +9,7 @@ import { BadRequestError, NotFoundError } from '@bytelyst/errors'; import { isFeatureEnabled } from '../../lib/feature-flags.js'; import { trackEvent } from '../../lib/telemetry.js'; import { embedText, cosineSimilarity, stripHtmlForEmbedding } from '../../lib/embeddings.js'; +import { estimateReadingTime } from '../../lib/reading-time.js'; import { llm } from '../../lib/llm.js'; import { CreatePromptTemplateSchema, @@ -108,6 +109,91 @@ export async function notePromptRoutes(app: FastifyInstance): Promise { return result; }); + // ── Run a prompt template with SSE streaming ──────────────────── + app.post('/note-prompts/run-stream', async (req, reply) => { + if (!isFeatureEnabled('notelett_smart_actions_enabled')) throw new BadRequestError('Smart Actions are disabled'); + const userId = getUserId(req); + const productId = getRequestProductId(req); + const input = RunPromptSchema.parse(req.body); + + let template = await repo.getPromptTemplate(input.templateId, userId); + if (!template) { + template = await repo.getPromptTemplate(input.templateId, '__builtin__'); + } + if (!template) { + template = await repo.getPromptTemplateBySlug(input.templateId, userId); + } + if (!template) throw new NotFoundError('Prompt template not found'); + + if ( + (template.inputType === 'image' || template.inputType === 'text+image') && + !input.imageUrl + ) { + throw new BadRequestError('This prompt requires an image URL'); + } + + const note = await noteRepo.getNote(input.noteId, input.workspaceId); + if (!note || note.userId !== userId || note.productId !== productId) { + throw new NotFoundError('Note not found'); + } + + const noteBody = note.body?.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim() ?? ''; + + // SSE headers + reply.raw.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }); + void reply.hijack(); + + try { + const result = await executePrompt(template, input, noteBody); + // Stream the result as a series of SSE events: tokens then done + const chunks = result.content.match(/.{1,80}/g) ?? [result.content]; + for (const chunk of chunks) { + reply.raw.write(`data: ${JSON.stringify({ type: 'token', content: chunk })}\n\n`); + } + reply.raw.write(`data: ${JSON.stringify({ type: 'done', templateSlug: result.templateSlug, outputType: result.outputType, model: result.model, usage: result.usage, approvalState: result.approvalState })}\n\n`); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Stream failed'; + reply.raw.write(`data: ${JSON.stringify({ type: 'error', message })}\n\n`); + } + reply.raw.end(); + }); + + // ── Prompt run history ────────────────────────────────────────── + app.get('/note-prompts/history', async (req) => { + const userId = getUserId(req); + const productId = getRequestProductId(req); + const { workspaceId, limit: limitStr } = req.query as { workspaceId: string; limit?: string }; + if (!workspaceId) throw new BadRequestError('workspaceId query param required'); + const limit = Math.min(Math.max(parseInt(limitStr || '20', 10) || 20, 1), 100); + + // History is derived from agent actions of type 'smart_action' + const { listNoteAgentActions } = await import('../note-agent-actions/repository.js'); + const { items, total } = await listNoteAgentActions(userId, productId, { + workspaceId, + actionType: 'smart_action', + limit, + offset: 0, + }); + + return { + items: items.map((a) => ({ + id: a.id, + noteId: a.noteId, + workspaceId: a.workspaceId, + toolName: a.toolName, + state: a.state, + reason: a.reason, + afterSummary: a.afterSummary, + createdAt: a.createdAt, + })), + total, + }; + }); + // ── Reading time estimate ─────────────────────────────────────── app.get('/notes/:id/reading-time', async (req) => { const userId = getUserId(req); @@ -122,11 +208,9 @@ export async function notePromptRoutes(app: FastifyInstance): Promise { throw new NotFoundError('Note not found'); } - const plainText = (note.body ?? '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(); - const wordCount = plainText.split(/\s+/).filter(Boolean).length; - const readingTimeMinutes = Math.max(1, Math.ceil(wordCount / 238)); + const { words, minutes } = estimateReadingTime(note.body ?? ''); - return { wordCount, readingTimeMinutes }; + return { wordCount: words, readingTimeMinutes: minutes }; }); // ── Suggest tags via LLM (F5) ────────────────────────────────── diff --git a/backend/src/modules/note-prompts/seed.ts b/backend/src/modules/note-prompts/seed.ts index fffaeed..05c4e0a 100644 --- a/backend/src/modules/note-prompts/seed.ts +++ b/backend/src/modules/note-prompts/seed.ts @@ -230,6 +230,19 @@ const TEMPLATES: SeedTemplate[] = [ userPromptTemplate: 'Convert this note into a social media post:\n\n{{noteBody}}', maxTokens: 512, }, + // ── Scheduled / System ─────────────────────────── + { + slug: 'weekly-digest', + name: 'Weekly Digest', + description: 'Generate a weekly workspace digest summarizing new and modified notes', + category: 'analyze', + inputType: 'text', + outputType: 'new_note', + systemPrompt: 'You produce a weekly digest for a workspace. Summarize key themes, list new notes, highlight the most active areas, and note any patterns. Use markdown headings.', + userPromptTemplate: 'Create a weekly digest for this workspace. Here are the notes created or modified this week:\n\n{{noteBody}}', + temperature: 0.4, + maxTokens: 2048, + }, ]; /** diff --git a/docs/SMART_ACTIONS_USER_GUIDE.md b/docs/SMART_ACTIONS_USER_GUIDE.md new file mode 100644 index 0000000..5cb5261 --- /dev/null +++ b/docs/SMART_ACTIONS_USER_GUIDE.md @@ -0,0 +1,180 @@ +# Smart Actions — User Guide + +> NoteLett's AI-powered features for transforming, analyzing, and enriching your notes. + +--- + +## What are Smart Actions? + +Smart Actions use large language models (LLMs) to help you work with your notes — summarizing, rewriting, extracting information, detecting duplicates, and more. They're available on both web and mobile. + +--- + +## Getting Started + +Smart Actions are gated behind the `notelett_smart_actions_enabled` feature flag. When enabled, you'll see the **Smart Actions** panel on note detail pages and a **Prompts** item in the sidebar. + +### Requirements + +- An LLM provider configured via environment variables (`LLM_PROVIDER`, `OPENAI_API_KEY`, etc.) +- Feature flag `notelett_smart_actions_enabled` set to `true` + +--- + +## 20 Built-in Prompt Templates + +### Transform +| Template | Description | Input | Output | +|----------|-------------|-------|--------| +| **Summarize** | Create a concise summary | text | new_note | +| **Shorten** | Condense while keeping key points | text | replace | +| **Expand** | Add more detail and examples | text | replace | +| **Bullet Points** | Convert to bullet points | text | replace | +| **Fix & Rewrite** | Fix grammar, improve clarity | text | replace | +| **Change Tone** | Rewrite in a different tone | text | replace | +| **Translate** | Translate to another language | text | new_note | + +### Extract +| Template | Description | Input | Output | +|----------|-------------|-------|--------| +| **Extract Key Facts** | Pull out key facts and data | text | artifact | +| **Extract Action Items** | Find action items and tasks | text | artifact | +| **Parse Receipt** | Extract line items from receipt | image | artifact | + +### Generate +| Template | Description | Input | Output | +|----------|-------------|-------|--------| +| **Continue Writing** | Continue from where you left off | text | replace | +| **Explain** | Explain selected concept | text | artifact | +| **Generate Outline** | Create a structured outline | text | new_note | + +### Analysis +| Template | Description | Input | Output | +|----------|-------------|-------|--------| +| **Compare Notes** | Compare 2-5 notes side by side | multi-note | new_note | +| **Merge Notes** | Combine notes into one | multi-note | new_note | +| **Rate Food Label** | Analyze nutritional info | image | artifact | + +### Export +| Template | Description | Input | Output | +|----------|-------------|-------|--------| +| **Shareable Summary** | Polished version for sharing | text | new_note | +| **Presentation Outline** | Slide-ready outline | text | new_note | +| **Email Draft** | Draft an email from notes | text | clipboard | +| **Social Post** | Generate a social media post | text | clipboard | + +--- + +## Using Smart Actions + +### On the Web + +1. **Open a note** → the Smart Actions panel appears on the right +2. **Click any action** → it runs immediately on the current note +3. **View the result** → copy, save as new note, apply to note, or discard +4. **Suggest Tags** → click to get AI-suggested tags, then accept individually + +### On Mobile + +1. **Open a note** → tap **Smart Actions** to expand the panel +2. **Tap an action** → runs and shows result inline +3. **Quick Capture** → 6 capture modes: Text, Photo, Voice, URL, Scan, Paste + +### Custom Templates + +1. Navigate to **Prompts** in the sidebar +2. Click **Create Custom Prompt** +3. Fill in name, category, system prompt, and user prompt template +4. Use template variables: `{{note.title}}`, `{{note.body}}`, `{{note.tags}}` + +--- + +## Intelligence Features + +### Reading Time (F10) +Displayed automatically on every note — word count and estimated reading time. + +### Tag Suggestions (F5) +Click "Suggest Tags" to get 3-5 AI-suggested tags based on note content. + +### Duplicate Detection (F8) +When `notelett_duplicate_check_enabled` is on, similar notes are flagged after save. + +### Related Notes (F9) +When `notelett_suggest_links_enabled` is on, related notes are suggested for linking. + +### Knowledge Gaps (F12) +Navigate to a workspace → **Knowledge Gaps** to analyze topic coverage and find gaps. + +### Auto-Summarize (F6) +When `notelett_auto_summarize_enabled` is on, long notes (300+ words) get automatic summaries. + +--- + +## Scheduled Actions (F25) + +Create scheduled prompts that run automatically: + +1. Go to the scheduler API: `POST /api/prompt-schedules` +2. Set a cron expression (e.g., `0 17 * * 5` for every Friday at 5pm) +3. Choose a template and workspace +4. The scheduler runs every 60 seconds and matches due schedules + +**Weekly Digest (F11)**: A special scheduled action that summarizes all notes created/modified in a workspace that week. + +--- + +## Webhook-Triggered Actions (F26) + +Set up webhooks to run prompts when events occur: + +- `note.created` — when a note is created +- `note.updated` — when a note is updated +- `note.tagged` — when a note is tagged +- `external` — triggered via API + +--- + +## Approval-Gated Actions (F27) + +Templates with `requiresApproval: true` create proposed actions instead of applying immediately. Review and approve/reject via the existing approval queue. + +--- + +## Feature Flags + +| Flag | Default | Controls | +|------|---------|----------| +| `notelett_smart_actions_enabled` | false | All Smart Actions UI + API | +| `notelett_auto_summarize_enabled` | false | Auto-summarize on save | +| `notelett_duplicate_check_enabled` | true | Duplicate detection | +| `notelett_suggest_links_enabled` | true | Auto-link suggestions | +| `notelett_auto_link_enabled` | false | Auto-link on save | +| `notelett_copilot_llm_enabled` | false | Editor AI (F1-F4) | +| `notelett_voice_capture_enabled` | false | Voice-to-note | +| `notelett_scheduled_actions_enabled` | false | Scheduled actions | +| `notelett_webhooks_enabled` | false | Webhook triggers | + +--- + +## Environment Variables + +| Variable | Description | +|----------|-------------| +| `LLM_PROVIDER` | Provider type: `azure`, `openai`, or `mock` | +| `OPENAI_API_KEY` | OpenAI API key (for openai provider) | +| `AZURE_OPENAI_ENDPOINT` | Azure OpenAI endpoint URL | +| `AZURE_OPENAI_KEY` | Azure OpenAI key | +| `AZURE_OPENAI_DEPLOYMENT` | Azure deployment name | +| `LLM_EMBEDDING_MODEL` | Embedding model override | + +--- + +## MCP Tools (for AI Agents) + +| Tool | Description | +|------|-------------| +| `notes.prompts.run` | Run any prompt template on a note | +| `notes.intelligence.suggest_tags` | Suggest tags for a note | +| `notes.intelligence.check_duplicates` | Check for duplicate notes | +| `notes.intelligence.suggest_links` | Suggest related notes to link | diff --git a/web/src/app/(app)/workspaces/[id]/gaps/page.tsx b/web/src/app/(app)/workspaces/[id]/gaps/page.tsx new file mode 100644 index 0000000..5f8f1d6 --- /dev/null +++ b/web/src/app/(app)/workspaces/[id]/gaps/page.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useParams } from "next/navigation"; +import { Brain, Plus, Loader2, AlertTriangle } from "lucide-react"; +import { getKnowledgeGaps } from "@/lib/prompt-client"; +import { toast } from "@/lib/toast"; +import type { KnowledgeGap } from "@/lib/types"; + +export default function KnowledgeGapsPage() { + const params = useParams<{ id: string }>(); + const workspaceId = params.id; + + const [gaps, setGaps] = useState([]); + const [topicMap, setTopicMap] = useState>({}); + const [loading, setLoading] = useState(false); + const [analyzed, setAnalyzed] = useState(false); + + async function handleAnalyze() { + setLoading(true); + try { + const res = await getKnowledgeGaps(workspaceId); + setGaps(res.gaps); + setTopicMap(res.topicMap ?? {}); + setAnalyzed(true); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Knowledge gap analysis failed"); + } finally { + setLoading(false); + } + } + + useEffect(() => { + // Auto-analyze on mount + void handleAnalyze(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [workspaceId]); + + return ( +
+ {/* Header */} +
+
+ +

Knowledge Gaps

+
+ +
+ + {/* Topic coverage map */} + {analyzed && Object.keys(topicMap).length > 0 && ( +
+ Topic Coverage +
+ {Object.entries(topicMap) + .sort(([, a], [, b]) => b - a) + .map(([topic, count]) => ( + + {topic} ({count}) + + ))} +
+
+ )} + + {/* Gaps list */} + {analyzed && gaps.length === 0 && !loading && ( +
+ No knowledge gaps detected. Your workspace has good topic coverage. +
+ )} + + {gaps.map((gap, i) => ( +
+
+ + {gap.topic} +
+

+ {gap.description} +

+
+ +
+
+ ))} +
+ ); +} diff --git a/web/src/components/PromptResultView.tsx b/web/src/components/PromptResultView.tsx new file mode 100644 index 0000000..a7caf0b --- /dev/null +++ b/web/src/components/PromptResultView.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { useState } from "react"; +import { Copy, FilePlus, Save, X, CheckCircle } from "lucide-react"; +import { toast } from "@/lib/toast"; +import type { RunPromptOutput } from "@/lib/types"; + +interface PromptResultViewProps { + result: RunPromptOutput; + onDismiss: () => void; + onSaveAsNote?: (content: string) => void; + onApplyToNote?: (content: string) => void; +} + +export function PromptResultView({ + result, + onDismiss, + onSaveAsNote, + onApplyToNote, +}: PromptResultViewProps) { + const [copied, setCopied] = useState(false); + + async function handleCopy() { + await navigator.clipboard.writeText(result.content); + setCopied(true); + toast.success("Copied to clipboard"); + setTimeout(() => setCopied(false), 2000); + } + + return ( +
+ {/* Header */} +
+ Result + +
+ + {/* Content */} +
+ {result.content} +
+ + {/* Action buttons */} +
+ {onSaveAsNote && ( + + )} + {onApplyToNote && ( + + )} + + +
+ + {/* Metadata footer */} + {(result.model || result.usage) && ( +
+ {result.model && Model: {result.model}} + {result.usage && {result.usage.totalTokens} tokens} + {result.approvalState && Status: {result.approvalState}} +
+ )} +
+ ); +} diff --git a/web/src/components/PromptTemplateEditor.tsx b/web/src/components/PromptTemplateEditor.tsx new file mode 100644 index 0000000..1deca19 --- /dev/null +++ b/web/src/components/PromptTemplateEditor.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { useState } from "react"; +import { Save, X } from "lucide-react"; +import { createPromptTemplate } from "@/lib/prompt-client"; +import { toast } from "@/lib/toast"; +import type { PromptCategory } from "@/lib/types"; + +const CATEGORIES: PromptCategory[] = ["transform", "extract", "generate", "analysis", "vision", "export", "custom"]; +const INPUT_TYPES = ["text", "image", "text+image", "multi-note"] as const; +const OUTPUT_TYPES = ["new_note", "replace", "artifact", "clipboard", "update_note"] as const; + +interface PromptTemplateEditorProps { + onClose: () => void; + onCreated?: () => void; +} + +export function PromptTemplateEditor({ onClose, onCreated }: PromptTemplateEditorProps) { + const [name, setName] = useState(""); + const [slug, setSlug] = useState(""); + const [description, setDescription] = useState(""); + const [category, setCategory] = useState("custom"); + const [inputType, setInputType] = useState<(typeof INPUT_TYPES)[number]>("text"); + const [outputType, setOutputType] = useState<(typeof OUTPUT_TYPES)[number]>("new_note"); + const [systemPrompt, setSystemPrompt] = useState(""); + const [userPromptTemplate, setUserPromptTemplate] = useState(""); + const [saving, setSaving] = useState(false); + + async function handleSave() { + if (!name.trim() || !slug.trim() || !systemPrompt.trim() || !userPromptTemplate.trim()) { + toast.error("Name, slug, system prompt, and user prompt template are required"); + return; + } + setSaving(true); + try { + await createPromptTemplate({ + name: name.trim(), + slug: slug.trim(), + description: description.trim(), + category, + inputType, + outputType, + systemPrompt: systemPrompt.trim(), + userPromptTemplate: userPromptTemplate.trim(), + }); + toast.success("Template created"); + onCreated?.(); + onClose(); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to create template"); + } finally { + setSaving(false); + } + } + + function autoSlug(value: string) { + setName(value); + if (!slug || slug === name.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "")) { + setSlug(value.toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "")); + } + } + + return ( +
{ if (e.target === e.currentTarget) onClose(); }} + role="dialog" + aria-modal="true" + aria-label="Create custom prompt template" + > +
+ {/* Header */} +
+ Create Custom Prompt + +
+ + {/* Name + slug */} +
+ + +
+ + {/* Description */} + + + {/* Category + input type + output type */} +
+ + + +
+ + {/* System prompt */} +