feat(smart-actions): add run-stream SSE, history endpoint, weekly-digest template, web client functions (G1-G5)

This commit is contained in:
saravanakumardb1 2026-04-06 13:27:02 -07:00
parent 4bb2d84152
commit 093da76eee
14 changed files with 985 additions and 7 deletions

View File

@ -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 };
}

View File

@ -13,6 +13,7 @@ export const NOTES_MCP_TOOL_NAMES = {
suggestTags: 'notes.intelligence.suggest_tags', suggestTags: 'notes.intelligence.suggest_tags',
checkDuplicates: 'notes.intelligence.check_duplicates', checkDuplicates: 'notes.intelligence.check_duplicates',
suggestLinks: 'notes.intelligence.suggest_links', suggestLinks: 'notes.intelligence.suggest_links',
runPrompt: 'notes.prompts.run',
} as const; } as const;
export const NoteToolRoleSchema = z.enum(['viewer', 'admin', 'super_admin']); 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<typeof RunPromptToolInputSchema>;
export type RunPromptToolOutput = z.infer<typeof RunPromptToolOutputSchema>;
export type SuggestTagsToolInput = z.infer<typeof SuggestTagsToolInputSchema>; export type SuggestTagsToolInput = z.infer<typeof SuggestTagsToolInputSchema>;
export type SuggestTagsToolOutput = z.infer<typeof SuggestTagsToolOutputSchema>; export type SuggestTagsToolOutput = z.infer<typeof SuggestTagsToolOutputSchema>;
export type CheckDuplicatesToolInput = z.infer<typeof CheckDuplicatesToolInputSchema>; export type CheckDuplicatesToolInput = z.infer<typeof CheckDuplicatesToolInputSchema>;

View File

@ -50,6 +50,7 @@ describe('note executable MCP tools', () => {
NOTES_MCP_TOOL_NAMES.suggestTags, NOTES_MCP_TOOL_NAMES.suggestTags,
NOTES_MCP_TOOL_NAMES.checkDuplicates, NOTES_MCP_TOOL_NAMES.checkDuplicates,
NOTES_MCP_TOOL_NAMES.suggestLinks, NOTES_MCP_TOOL_NAMES.suggestLinks,
NOTES_MCP_TOOL_NAMES.runPrompt,
]); ]);
}); });

View File

@ -26,6 +26,9 @@ import {
CheckDuplicatesToolOutputSchema, CheckDuplicatesToolOutputSchema,
SuggestLinksToolOutputSchema, SuggestLinksToolOutputSchema,
SmartActionMcpToolDefinitions, SmartActionMcpToolDefinitions,
RunPromptMcpToolDefinition,
RunPromptToolOutputSchema,
type RunPromptToolInput,
type AttachArtifactToolInput, type AttachArtifactToolInput,
type CreateNoteDraftToolInput, type CreateNoteDraftToolInput,
type ExtractTasksToolInput, 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 ───────────────────── // ── Smart Action MCP tool implementations ─────────────────────
async function executeSuggestTags(args: SuggestTagsToolInput, req: NotesMcpRequest) { async function executeSuggestTags(args: SuggestTagsToolInput, req: NotesMcpRequest) {
@ -639,6 +699,7 @@ export const NotesExecutableMcpTools: Array<
| NotesMcpTool<SuggestTagsToolInput> | NotesMcpTool<SuggestTagsToolInput>
| NotesMcpTool<CheckDuplicatesToolInput> | NotesMcpTool<CheckDuplicatesToolInput>
| NotesMcpTool<SuggestLinksToolInput> | NotesMcpTool<SuggestLinksToolInput>
| NotesMcpTool<RunPromptToolInput>
> = [ > = [
{ {
...NotesMcpToolDefinitions.list, ...NotesMcpToolDefinitions.list,
@ -684,6 +745,10 @@ export const NotesExecutableMcpTools: Array<
...SmartActionMcpToolDefinitions.suggestLinks, ...SmartActionMcpToolDefinitions.suggestLinks,
execute: executeSuggestLinks, execute: executeSuggestLinks,
}, },
{
...RunPromptMcpToolDefinition,
execute: executeRunPrompt,
},
]; ];
export function getNotesExecutableMcpTool(name: string) { export function getNotesExecutableMcpTool(name: string) {

View File

@ -272,9 +272,9 @@ describe('reading-time', () => {
}); });
describe('seed', () => { describe('seed', () => {
it('getBuiltinTemplates returns 20 templates', () => { it('getBuiltinTemplates returns 21 templates', () => {
const templates = getBuiltinTemplates(); 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.isBuiltin)).toBe(true);
expect(templates.every((t) => t.id.startsWith('builtin-'))).toBe(true); expect(templates.every((t) => t.id.startsWith('builtin-'))).toBe(true);
}); });

View File

@ -9,6 +9,7 @@ import { BadRequestError, NotFoundError } from '@bytelyst/errors';
import { isFeatureEnabled } from '../../lib/feature-flags.js'; import { isFeatureEnabled } from '../../lib/feature-flags.js';
import { trackEvent } from '../../lib/telemetry.js'; import { trackEvent } from '../../lib/telemetry.js';
import { embedText, cosineSimilarity, stripHtmlForEmbedding } from '../../lib/embeddings.js'; import { embedText, cosineSimilarity, stripHtmlForEmbedding } from '../../lib/embeddings.js';
import { estimateReadingTime } from '../../lib/reading-time.js';
import { llm } from '../../lib/llm.js'; import { llm } from '../../lib/llm.js';
import { import {
CreatePromptTemplateSchema, CreatePromptTemplateSchema,
@ -108,6 +109,91 @@ export async function notePromptRoutes(app: FastifyInstance): Promise<void> {
return result; 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 ─────────────────────────────────────── // ── Reading time estimate ───────────────────────────────────────
app.get('/notes/:id/reading-time', async (req) => { app.get('/notes/:id/reading-time', async (req) => {
const userId = getUserId(req); const userId = getUserId(req);
@ -122,11 +208,9 @@ export async function notePromptRoutes(app: FastifyInstance): Promise<void> {
throw new NotFoundError('Note not found'); throw new NotFoundError('Note not found');
} }
const plainText = (note.body ?? '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(); const { words, minutes } = estimateReadingTime(note.body ?? '');
const wordCount = plainText.split(/\s+/).filter(Boolean).length;
const readingTimeMinutes = Math.max(1, Math.ceil(wordCount / 238));
return { wordCount, readingTimeMinutes }; return { wordCount: words, readingTimeMinutes: minutes };
}); });
// ── Suggest tags via LLM (F5) ────────────────────────────────── // ── Suggest tags via LLM (F5) ──────────────────────────────────

View File

@ -230,6 +230,19 @@ const TEMPLATES: SeedTemplate[] = [
userPromptTemplate: 'Convert this note into a social media post:\n\n{{noteBody}}', userPromptTemplate: 'Convert this note into a social media post:\n\n{{noteBody}}',
maxTokens: 512, 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,
},
]; ];
/** /**

View File

@ -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 |

View File

@ -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<KnowledgeGap[]>([]);
const [topicMap, setTopicMap] = useState<Record<string, number>>({});
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 (
<div style={{ padding: "var(--nl-space-6)", maxWidth: 800, margin: "0 auto", display: "grid", gap: "var(--nl-space-5)" }}>
{/* Header */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div style={{ display: "flex", alignItems: "center", gap: "var(--nl-space-2)" }}>
<Brain size={20} />
<h1 style={{ fontSize: "var(--nl-fs-xl)", fontWeight: 700, margin: 0 }}>Knowledge Gaps</h1>
</div>
<button
className="btn btn-primary"
disabled={loading}
onClick={() => void handleAnalyze()}
aria-label={loading ? "Analyzing..." : "Analyze knowledge gaps"}
style={{ display: "flex", alignItems: "center", gap: "var(--nl-space-1)" }}
>
{loading ? <Loader2 size={16} className="animate-spin" /> : <Brain size={16} />}
{loading ? "Analyzing..." : "Re-analyze"}
</button>
</div>
{/* Topic coverage map */}
{analyzed && Object.keys(topicMap).length > 0 && (
<div className="surface-card" style={{ padding: "var(--nl-space-4)", display: "grid", gap: "var(--nl-space-3)" }}>
<strong style={{ fontSize: "var(--nl-fs-sm)" }}>Topic Coverage</strong>
<div style={{ display: "flex", gap: "var(--nl-space-2)", flexWrap: "wrap" }}>
{Object.entries(topicMap)
.sort(([, a], [, b]) => b - a)
.map(([topic, count]) => (
<span key={topic} className="badge" style={{ fontSize: "var(--nl-fs-xs)" }}>
{topic} ({count})
</span>
))}
</div>
</div>
)}
{/* Gaps list */}
{analyzed && gaps.length === 0 && !loading && (
<div className="surface-muted" style={{ padding: "var(--nl-space-6)", textAlign: "center", color: "var(--nl-text-secondary)" }}>
No knowledge gaps detected. Your workspace has good topic coverage.
</div>
)}
{gaps.map((gap, i) => (
<div key={i} className="surface-card" style={{ padding: "var(--nl-space-4)", display: "grid", gap: "var(--nl-space-2)" }}>
<div style={{ display: "flex", alignItems: "center", gap: "var(--nl-space-2)" }}>
<AlertTriangle size={14} style={{ color: "var(--nl-warning)" }} />
<strong style={{ fontSize: "var(--nl-fs-md)" }}>{gap.topic}</strong>
</div>
<p style={{ fontSize: "var(--nl-fs-sm)", color: "var(--nl-text-secondary)", margin: 0 }}>
{gap.description}
</p>
<div style={{ display: "flex", gap: "var(--nl-space-2)" }}>
<button
className="btn btn-secondary"
style={{ fontSize: "var(--nl-fs-sm)", display: "flex", alignItems: "center", gap: "var(--nl-space-1)" }}
onClick={() => {
toast.info(`Create note: "${gap.suggestedTitle}" (navigate to create)`);
}}
aria-label={`Create note: ${gap.suggestedTitle}`}
>
<Plus size={14} /> Create: {gap.suggestedTitle}
</button>
</div>
</div>
))}
</div>
);
}

View File

@ -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 (
<div className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-4)" }}>
{/* Header */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<strong style={{ fontSize: "var(--nl-fs-md)" }}>Result</strong>
<button
className="btn btn-secondary"
onClick={onDismiss}
aria-label="Dismiss result"
style={{ padding: 4 }}
>
<X size={16} />
</button>
</div>
{/* Content */}
<div
style={{
whiteSpace: "pre-wrap",
fontSize: "var(--nl-fs-sm)",
maxHeight: 400,
overflowY: "auto",
padding: "var(--nl-space-3)",
borderRadius: "var(--nl-radius-md)",
backgroundColor: "var(--nl-bg-secondary)",
lineHeight: 1.6,
}}
>
{result.content}
</div>
{/* Action buttons */}
<div style={{ display: "flex", gap: "var(--nl-space-2)", flexWrap: "wrap" }}>
{onSaveAsNote && (
<button
className="btn btn-primary"
onClick={() => onSaveAsNote(result.content)}
aria-label="Save as new note"
style={{ display: "flex", alignItems: "center", gap: "var(--nl-space-1)" }}
>
<FilePlus size={14} /> Save as Note
</button>
)}
{onApplyToNote && (
<button
className="btn btn-secondary"
onClick={() => onApplyToNote(result.content)}
aria-label="Apply to current note"
style={{ display: "flex", alignItems: "center", gap: "var(--nl-space-1)" }}
>
<Save size={14} /> Apply to Note
</button>
)}
<button
className="btn btn-secondary"
onClick={() => void handleCopy()}
aria-label="Copy result to clipboard"
style={{ display: "flex", alignItems: "center", gap: "var(--nl-space-1)" }}
>
{copied ? <CheckCircle size={14} /> : <Copy size={14} />}
{copied ? "Copied" : "Copy"}
</button>
<button className="btn btn-secondary" onClick={onDismiss} aria-label="Discard result">
Discard
</button>
</div>
{/* Metadata footer */}
{(result.model || result.usage) && (
<div style={{ fontSize: "var(--nl-fs-xs)", color: "var(--nl-text-secondary)", display: "flex", gap: "var(--nl-space-3)" }}>
{result.model && <span>Model: {result.model}</span>}
{result.usage && <span>{result.usage.totalTokens} tokens</span>}
{result.approvalState && <span>Status: {result.approvalState}</span>}
</div>
)}
</div>
);
}

View File

@ -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<PromptCategory>("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 (
<div
style={{ position: "fixed", inset: 0, zIndex: 100, display: "grid", placeItems: "center", backgroundColor: "rgba(0,0,0,0.5)" }}
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
role="dialog"
aria-modal="true"
aria-label="Create custom prompt template"
>
<div
className="surface-card"
style={{ width: "min(90vw, 600px)", maxHeight: "90vh", overflowY: "auto", padding: "var(--nl-space-6)", display: "grid", gap: "var(--nl-space-4)" }}
>
{/* Header */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<strong>Create Custom Prompt</strong>
<button className="btn btn-secondary" onClick={onClose} aria-label="Close editor" style={{ padding: 4 }}>
<X size={16} />
</button>
</div>
{/* Name + slug */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "var(--nl-space-3)" }}>
<label style={{ display: "grid", gap: "var(--nl-space-1)" }}>
<span style={{ fontSize: "var(--nl-fs-sm)", fontWeight: 600 }}>Name</span>
<input className="input" value={name} onChange={(e) => autoSlug(e.target.value)} placeholder="My Action" aria-label="Template name" />
</label>
<label style={{ display: "grid", gap: "var(--nl-space-1)" }}>
<span style={{ fontSize: "var(--nl-fs-sm)", fontWeight: 600 }}>Slug</span>
<input className="input" value={slug} onChange={(e) => setSlug(e.target.value)} placeholder="my-action" aria-label="Template slug" />
</label>
</div>
{/* Description */}
<label style={{ display: "grid", gap: "var(--nl-space-1)" }}>
<span style={{ fontSize: "var(--nl-fs-sm)", fontWeight: 600 }}>Description</span>
<input className="input" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="What this action does" aria-label="Description" />
</label>
{/* Category + input type + output type */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "var(--nl-space-3)" }}>
<label style={{ display: "grid", gap: "var(--nl-space-1)" }}>
<span style={{ fontSize: "var(--nl-fs-sm)", fontWeight: 600 }}>Category</span>
<select className="input" value={category} onChange={(e) => setCategory(e.target.value as PromptCategory)} aria-label="Category">
{CATEGORIES.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
</label>
<label style={{ display: "grid", gap: "var(--nl-space-1)" }}>
<span style={{ fontSize: "var(--nl-fs-sm)", fontWeight: 600 }}>Input type</span>
<select className="input" value={inputType} onChange={(e) => setInputType(e.target.value as (typeof INPUT_TYPES)[number])} aria-label="Input type">
{INPUT_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
</label>
<label style={{ display: "grid", gap: "var(--nl-space-1)" }}>
<span style={{ fontSize: "var(--nl-fs-sm)", fontWeight: 600 }}>Output</span>
<select className="input" value={outputType} onChange={(e) => setOutputType(e.target.value as (typeof OUTPUT_TYPES)[number])} aria-label="Output type">
{OUTPUT_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
</label>
</div>
{/* System prompt */}
<label style={{ display: "grid", gap: "var(--nl-space-1)" }}>
<span style={{ fontSize: "var(--nl-fs-sm)", fontWeight: 600 }}>System prompt</span>
<textarea className="input" rows={4} value={systemPrompt} onChange={(e) => setSystemPrompt(e.target.value)} placeholder="You are a helpful assistant that..." aria-label="System prompt" />
</label>
{/* User prompt template */}
<label style={{ display: "grid", gap: "var(--nl-space-1)" }}>
<span style={{ fontSize: "var(--nl-fs-sm)", fontWeight: 600 }}>User prompt template</span>
<textarea className="input" rows={4} value={userPromptTemplate} onChange={(e) => setUserPromptTemplate(e.target.value)} placeholder="{{noteBody}}" aria-label="User prompt template" />
<span style={{ fontSize: "var(--nl-fs-xs)", color: "var(--nl-text-secondary)" }}>
Variables: {"{{note.title}}"}, {"{{note.body}}"}, {"{{note.tags}}"}, {"{{params.X}}"}
</span>
</label>
{/* Save */}
<button
className="btn btn-primary"
disabled={saving}
onClick={() => void handleSave()}
aria-label={saving ? "Saving..." : "Create template"}
style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: "var(--nl-space-2)" }}
>
<Save size={16} /> {saving ? "Saving..." : "Create Template"}
</button>
</div>
</div>
);
}

View File

@ -0,0 +1,150 @@
"use client";
import { useState } from "react";
import { X, Play, Loader2 } from "lucide-react";
import { runPrompt } from "@/lib/prompt-client";
import { toast } from "@/lib/toast";
import type { PromptTemplate, RunPromptOutput } from "@/lib/types";
interface RunPromptModalProps {
template: PromptTemplate;
noteId: string;
workspaceId: string;
onClose: () => void;
onResult: (result: RunPromptOutput) => void;
}
export function RunPromptModal({
template,
noteId,
workspaceId,
onClose,
onResult,
}: RunPromptModalProps) {
const [inlinePrompt, setInlinePrompt] = useState("");
const [additionalNoteIds, setAdditionalNoteIds] = useState("");
const [dryRun, setDryRun] = useState(false);
const [running, setRunning] = useState(false);
const isMultiNote = template.inputType === "multi-note";
async function handleRun() {
setRunning(true);
try {
const input: Record<string, unknown> = {
templateId: template.slug,
noteId,
workspaceId,
dryRun,
};
if (inlinePrompt.trim()) {
input.inlinePrompt = inlinePrompt.trim();
}
if (isMultiNote && additionalNoteIds.trim()) {
input.additionalNoteIds = additionalNoteIds
.split(",")
.map((s) => s.trim())
.filter(Boolean);
}
const result = await runPrompt(input as Parameters<typeof runPrompt>[0]);
onResult(result);
toast.success(`"${template.name}" completed`);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Prompt execution failed");
} finally {
setRunning(false);
}
}
return (
<div
style={{
position: "fixed",
inset: 0,
zIndex: 100,
display: "grid",
placeItems: "center",
backgroundColor: "rgba(0,0,0,0.5)",
}}
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
role="dialog"
aria-modal="true"
aria-label={`Run: ${template.name}`}
>
<div
className="surface-card"
style={{
width: "min(90vw, 520px)",
padding: "var(--nl-space-6)",
display: "grid",
gap: "var(--nl-space-4)",
}}
>
{/* Header */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<strong>{template.name}</strong>
<button className="btn btn-secondary" onClick={onClose} aria-label="Close modal" style={{ padding: 4 }}>
<X size={16} />
</button>
</div>
<p style={{ fontSize: "var(--nl-fs-sm)", color: "var(--nl-text-secondary)" }}>
{template.description}
</p>
{/* Inline prompt override */}
<label style={{ display: "grid", gap: "var(--nl-space-1)" }}>
<span style={{ fontSize: "var(--nl-fs-sm)", fontWeight: 600 }}>Custom instructions (optional)</span>
<textarea
className="input"
rows={3}
value={inlinePrompt}
onChange={(e) => setInlinePrompt(e.target.value)}
placeholder="Add specific instructions to override or extend the template..."
aria-label="Custom instructions"
/>
</label>
{/* Multi-note selector */}
{isMultiNote && (
<label style={{ display: "grid", gap: "var(--nl-space-1)" }}>
<span style={{ fontSize: "var(--nl-fs-sm)", fontWeight: 600 }}>Additional note IDs (comma-separated)</span>
<input
className="input"
type="text"
value={additionalNoteIds}
onChange={(e) => setAdditionalNoteIds(e.target.value)}
placeholder="note-id-1, note-id-2"
aria-label="Additional note IDs"
/>
</label>
)}
{/* Dry run toggle */}
<label style={{ display: "flex", gap: "var(--nl-space-2)", alignItems: "center", fontSize: "var(--nl-fs-sm)" }}>
<input type="checkbox" checked={dryRun} onChange={(e) => setDryRun(e.target.checked)} />
Dry run (preview without saving)
</label>
{/* Info badges */}
<div style={{ display: "flex", gap: "var(--nl-space-2)", flexWrap: "wrap" }}>
<span className="badge">{template.inputType}</span>
<span className="badge">{template.outputType}</span>
<span className="badge">{template.category}</span>
</div>
{/* Run button */}
<button
className="btn btn-primary"
disabled={running}
onClick={() => void handleRun()}
aria-label={running ? "Running prompt..." : "Run prompt"}
style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: "var(--nl-space-2)" }}
>
{running ? <Loader2 size={16} className="animate-spin" /> : <Play size={16} />}
{running ? "Running..." : "Run"}
</button>
</div>
</div>
);
}

View File

@ -47,6 +47,74 @@ export async function runPrompt(input: RunPromptInput): Promise<RunPromptOutput>
}); });
} }
// ── Run prompts (streaming) ───────────────────────────────────
export async function runPromptStream(
input: RunPromptInput,
onToken: (content: string) => void,
onDone: (meta: Partial<RunPromptOutput>) => void,
onError?: (message: string) => void,
): Promise<void> {
const api = createNotesApiClient();
const baseUrl = (api as unknown as { baseUrl?: string }).baseUrl ?? "";
const token = typeof window !== "undefined" ? localStorage.getItem("access_token") : null;
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (token) headers["Authorization"] = `Bearer ${token}`;
const res = await fetch(`${baseUrl}/note-prompts/run-stream`, {
method: "POST",
headers,
body: JSON.stringify(input),
});
if (!res.ok || !res.body) {
onError?.(res.statusText || "Stream failed");
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
try {
const payload = JSON.parse(line.slice(6));
if (payload.type === "token") onToken(payload.content);
else if (payload.type === "done") onDone(payload);
else if (payload.type === "error") onError?.(payload.message);
} catch { /* skip malformed */ }
}
}
}
// ── History ──────────────────────────────────────────────────
export interface PromptHistoryItem {
id: string;
noteId: string;
workspaceId: string;
toolName?: string;
state: string;
reason?: string;
afterSummary?: string;
createdAt: string;
}
export async function listPromptHistory(
workspaceId: string,
limit = 20,
): Promise<{ items: PromptHistoryItem[]; total: number }> {
const api = createNotesApiClient();
return api.fetch(`/note-prompts/history?workspaceId=${encodeURIComponent(workspaceId)}&limit=${limit}`);
}
// ── Intelligence endpoints ──────────────────────────────────── // ── Intelligence endpoints ────────────────────────────────────
export async function suggestTags( export async function suggestTags(

View File

@ -179,7 +179,7 @@ export type NoteRelationshipDoc = {
export type PromptCategory = "transform" | "extract" | "generate" | "analysis" | "vision" | "export" | "custom"; export type PromptCategory = "transform" | "extract" | "generate" | "analysis" | "vision" | "export" | "custom";
export type PromptInputType = "text" | "image" | "text+image" | "multi-note"; export type PromptInputType = "text" | "image" | "text+image" | "multi-note";
export type PromptOutputType = "new_note" | "artifact" | "update_note"; export type PromptOutputType = "new_note" | "artifact" | "update_note" | "replace" | "clipboard";
export interface PromptTemplate { export interface PromptTemplate {
id: string; id: string;
@ -208,6 +208,9 @@ export interface RunPromptOutput {
outputType: PromptOutputType; outputType: PromptOutputType;
model?: string; model?: string;
usage?: { promptTokens: number; completionTokens: number; totalTokens: number }; usage?: { promptTokens: number; completionTokens: number; totalTokens: number };
approvalState?: string;
resultNoteId?: string | null;
resultArtifactId?: string | null;
} }
export interface SimilarNote { export interface SimilarNote {