feat(web): add Smart Actions UI — prompt client, SmartActionsPanel, prompts page

Phase 3 of Smart Actions Roadmap:

- Create web/src/lib/prompt-client.ts: typed API client for all prompt endpoints
  - listPromptTemplates, getPromptTemplate, createPromptTemplate, deletePromptTemplate
  - runPrompt, suggestTags, checkDuplicates, suggestLinks, getReadingTime
  - compareNotes, mergeNotes, getKnowledgeGaps
- Add Smart Actions types to web/src/lib/types.ts:
  - PromptTemplate, RunPromptInput, RunPromptOutput, SimilarNote, KnowledgeGap
- Create SmartActionsPanel component:
  - Reading time display, tag suggestion with accept/dismiss
  - Category filter tabs, 2-column template grid
  - One-click prompt execution with loading state
  - Inline result display with copy/dismiss
- Create /prompts template library page:
  - Browse built-in + custom templates with category filter
  - Grid layout with input/output type badges
  - Delete custom templates
- Wire SmartActionsPanel into note detail sidebar (above MetadataPanel)
- Add 'Prompts' nav item to Sidebar (Sparkles icon)
- Web typecheck passes, all 131 backend tests pass
This commit is contained in:
saravanakumardb1 2026-04-06 08:19:35 -07:00
parent fe3b0f9b3e
commit 564e8f72dc
6 changed files with 556 additions and 0 deletions

View File

@ -11,6 +11,7 @@ import { TaskReviewPanel } from "@/components/TaskReviewPanel";
import { ExtractedTasksPanel } from "@/components/ExtractedTasksPanel";
import { ArtifactPanel } from "@/components/ArtifactPanel";
import { AgentTimeline } from "@/components/AgentTimeline";
import { SmartActionsPanel } from "@/components/SmartActionsPanel";
import { LinkNoteModal } from "@/components/LinkNoteModal";
import {
archiveNote,
@ -268,6 +269,13 @@ export default function NoteDetailPage() {
<aside style={{ display: "grid", gap: "var(--nl-space-4)" }}>
{error ? <div className="surface-card" style={{ padding: "var(--nl-space-4)", color: "var(--nl-text-secondary)" }}>{error}</div> : null}
<SmartActionsPanel
noteId={note.id}
workspaceId={note.workspaceId}
noteTags={note.tags}
onTagsAccepted={(tags) => void handleSave({ title: note.title, body: note.body }, { quiet: true })}
onResultCreated={() => void getNoteDetail(note.id, note.workspaceId).then(setNote)}
/>
<MetadataPanel note={note} />
<NoteVersionsPanel noteId={note.id} workspaceId={note.workspaceId} />
<LinkedNotesPanel linkedNotes={note.linkedNotes} />

View File

@ -0,0 +1,175 @@
"use client";
import { useEffect, useState } from "react";
import { Sparkles, FileText, Image, Layers, Trash2 } from "lucide-react";
import { AppShell } from "@/components/AppShell";
import { listPromptTemplates, deletePromptTemplate } from "@/lib/prompt-client";
import { toast } from "@/lib/toast";
import type { PromptTemplate, PromptCategory } from "@/lib/types";
const CATEGORY_LABELS: Record<PromptCategory, string> = {
transform: "Transform",
extract: "Extract",
generate: "Generate",
analysis: "Analysis",
vision: "Vision",
export: "Export",
custom: "Custom",
};
const INPUT_ICONS: Record<string, typeof FileText> = {
text: FileText,
image: Image,
"text+image": Layers,
"multi-note": Layers,
};
export default function PromptsPage() {
const [templates, setTemplates] = useState<PromptTemplate[]>([]);
const [activeCategory, setActiveCategory] = useState<PromptCategory | "all">("all");
const [loading, setLoading] = useState(true);
useEffect(() => {
void loadTemplates();
}, []);
async function loadTemplates() {
setLoading(true);
try {
setTemplates(await listPromptTemplates());
} catch {
toast.error("Could not load templates");
} finally {
setLoading(false);
}
}
async function handleDelete(id: string) {
if (!confirm("Delete this custom template?")) return;
try {
await deletePromptTemplate(id);
setTemplates((prev) => prev.filter((t) => t.id !== id));
toast.success("Template deleted");
} catch {
toast.error("Could not delete template");
}
}
const categories = Array.from(new Set(templates.map((t) => t.category)));
const filtered = activeCategory === "all"
? templates
: templates.filter((t) => t.category === activeCategory);
const builtIn = filtered.filter((t) => t.builtIn);
const custom = filtered.filter((t) => !t.builtIn);
return (
<AppShell
title="Prompt Templates"
description="Browse built-in and custom Smart Action templates."
actions={
<div className="badge">
<Sparkles size={14} /> {templates.length} templates
</div>
}
>
{/* Category filter */}
<div style={{ display: "flex", gap: "var(--nl-space-2)", flexWrap: "wrap", marginBottom: "var(--nl-space-4)" }}>
<button
className={`badge ${activeCategory === "all" ? "" : "surface-muted"}`}
onClick={() => setActiveCategory("all")}
aria-label="All categories"
>
All ({templates.length})
</button>
{categories.map((cat) => (
<button
key={cat}
className={`badge ${activeCategory === cat ? "" : "surface-muted"}`}
onClick={() => setActiveCategory(cat)}
aria-label={`Filter: ${CATEGORY_LABELS[cat]}`}
>
{CATEGORY_LABELS[cat]} ({templates.filter((t) => t.category === cat).length})
</button>
))}
</div>
{loading && (
<div className="surface-card" style={{ padding: "var(--nl-space-6)", textAlign: "center", color: "var(--nl-text-secondary)" }}>
Loading templates
</div>
)}
{/* Built-in templates */}
{builtIn.length > 0 && (
<section style={{ marginBottom: "var(--nl-space-6)" }}>
<h2 style={{ fontSize: "var(--nl-fs-lg)", marginBottom: "var(--nl-space-3)" }}>Built-in Templates</h2>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))", gap: "var(--nl-space-3)" }}>
{builtIn.map((t) => {
const Icon = INPUT_ICONS[t.inputType] ?? FileText;
return (
<div key={t.id} className="surface-card" style={{ padding: "var(--nl-space-4)", display: "grid", gap: "var(--nl-space-2)" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontWeight: 600, display: "flex", alignItems: "center", gap: 6 }}>
<Icon size={14} />
{t.name}
</span>
<span className="badge" style={{ fontSize: "var(--nl-fs-xs)" }}>{CATEGORY_LABELS[t.category]}</span>
</div>
<p style={{ fontSize: "var(--nl-fs-sm)", color: "var(--nl-text-secondary)", margin: 0 }}>{t.description}</p>
<div style={{ display: "flex", gap: "var(--nl-space-2)", fontSize: "var(--nl-fs-xs)", color: "var(--nl-text-secondary)" }}>
<span>Input: {t.inputType}</span>
<span>&middot;</span>
<span>Output: {t.outputType}</span>
</div>
</div>
);
})}
</div>
</section>
)}
{/* Custom templates */}
{custom.length > 0 && (
<section>
<h2 style={{ fontSize: "var(--nl-fs-lg)", marginBottom: "var(--nl-space-3)" }}>Custom Templates</h2>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))", gap: "var(--nl-space-3)" }}>
{custom.map((t) => {
const Icon = INPUT_ICONS[t.inputType] ?? FileText;
return (
<div key={t.id} className="surface-card" style={{ padding: "var(--nl-space-4)", display: "grid", gap: "var(--nl-space-2)" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontWeight: 600, display: "flex", alignItems: "center", gap: 6 }}>
<Icon size={14} />
{t.name}
</span>
<button
className="btn btn-secondary"
style={{ fontSize: "var(--nl-fs-xs)", padding: "2px 6px" }}
onClick={() => void handleDelete(t.id)}
aria-label={`Delete template: ${t.name}`}
>
<Trash2 size={12} />
</button>
</div>
<p style={{ fontSize: "var(--nl-fs-sm)", color: "var(--nl-text-secondary)", margin: 0 }}>{t.description}</p>
<div style={{ display: "flex", gap: "var(--nl-space-2)", fontSize: "var(--nl-fs-xs)", color: "var(--nl-text-secondary)" }}>
<span>Input: {t.inputType}</span>
<span>&middot;</span>
<span>Output: {t.outputType}</span>
</div>
</div>
);
})}
</div>
</section>
)}
{!loading && filtered.length === 0 && (
<div className="surface-card" style={{ padding: "var(--nl-space-6)", textAlign: "center", color: "var(--nl-text-secondary)" }}>
No templates found for this category.
</div>
)}
</AppShell>
);
}

View File

@ -10,6 +10,7 @@ const navItems: { href: string; label: string; icon: typeof House; flag?: string
{ href: "/dashboard", label: "Dashboard", icon: House },
{ href: "/workspaces", label: "Workspaces", icon: FolderKanban },
{ href: "/reviews", label: "Reviews", icon: ShieldCheck, flag: "mcp_tools_enabled" },
{ href: "/prompts", label: "Prompts", icon: Sparkles },
{ href: "/search", label: "Search", icon: Search },
{ href: "/chat", label: "Workspace chat", icon: MessageCircle },
{ href: "/settings", label: "Settings", icon: Settings },

View File

@ -0,0 +1,198 @@
"use client";
import { useEffect, useState } from "react";
import { Sparkles, Clock, Tag, Copy, FileText, GitCompare, Loader2 } from "lucide-react";
import { listPromptTemplates, runPrompt, suggestTags, getReadingTime } from "@/lib/prompt-client";
import { toast } from "@/lib/toast";
import type { PromptTemplate, PromptCategory, RunPromptOutput } from "@/lib/types";
const CATEGORY_LABELS: Record<PromptCategory, string> = {
transform: "Transform",
extract: "Extract",
generate: "Generate",
analysis: "Analysis",
vision: "Vision",
export: "Export",
custom: "Custom",
};
interface SmartActionsPanelProps {
noteId: string;
workspaceId: string;
noteTags: string[];
onTagsAccepted?: (tags: string[]) => void;
onResultCreated?: () => void;
}
export function SmartActionsPanel({
noteId,
workspaceId,
noteTags,
onTagsAccepted,
onResultCreated,
}: SmartActionsPanelProps) {
const [templates, setTemplates] = useState<PromptTemplate[]>([]);
const [readingTime, setReadingTime] = useState<{ wordCount: number; readingTimeMinutes: number } | null>(null);
const [suggestedTags, setSuggestedTags] = useState<string[]>([]);
const [runningId, setRunningId] = useState<string | null>(null);
const [result, setResult] = useState<RunPromptOutput | null>(null);
const [activeCategory, setActiveCategory] = useState<PromptCategory | "all">("all");
useEffect(() => {
void listPromptTemplates().then(setTemplates).catch(() => {});
void getReadingTime(noteId, workspaceId).then(setReadingTime).catch(() => {});
}, [noteId, workspaceId]);
const filtered = activeCategory === "all"
? templates
: templates.filter((t) => t.category === activeCategory);
const categories = Array.from(new Set(templates.map((t) => t.category)));
async function handleRun(template: PromptTemplate) {
setRunningId(template.id);
setResult(null);
try {
const res = await runPrompt({ templateId: template.slug, noteId, workspaceId });
setResult(res);
onResultCreated?.();
toast.success(`"${template.name}" completed`);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Smart action failed");
} finally {
setRunningId(null);
}
}
async function handleSuggestTags() {
try {
const tags = await suggestTags(noteId, workspaceId);
setSuggestedTags(tags.filter((t) => !noteTags.includes(t)));
} catch {
toast.error("Could not suggest tags");
}
}
function handleAcceptTag(tag: string) {
onTagsAccepted?.([...noteTags, tag]);
setSuggestedTags((prev) => prev.filter((t) => t !== tag));
}
return (
<section className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-4)" }}>
<div style={{ display: "flex", alignItems: "center", gap: "var(--nl-space-2)" }}>
<Sparkles size={16} />
<strong>Smart Actions</strong>
</div>
{/* Reading time */}
{readingTime && (
<div style={{ display: "flex", alignItems: "center", gap: "var(--nl-space-2)", color: "var(--nl-text-secondary)", fontSize: "var(--nl-fs-sm)" }}>
<Clock size={14} />
<span>{readingTime.readingTimeMinutes} min read &middot; {readingTime.wordCount} words</span>
</div>
)}
{/* Tag suggestions */}
<div style={{ display: "flex", gap: "var(--nl-space-2)", flexWrap: "wrap", alignItems: "center" }}>
<button className="btn btn-secondary" style={{ fontSize: "var(--nl-fs-sm)", padding: "4px 10px" }} onClick={() => void handleSuggestTags()} aria-label="Suggest tags">
<Tag size={14} /> Suggest tags
</button>
{suggestedTags.map((tag) => (
<button
key={tag}
className="badge"
style={{ cursor: "pointer" }}
onClick={() => handleAcceptTag(tag)}
aria-label={`Accept tag: ${tag}`}
>
+ {tag}
</button>
))}
</div>
{/* Category filter */}
<div style={{ display: "flex", gap: "var(--nl-space-1)", flexWrap: "wrap" }}>
<button
className={`badge ${activeCategory === "all" ? "" : "surface-muted"}`}
onClick={() => setActiveCategory("all")}
aria-label="All categories"
>
All
</button>
{categories.map((cat) => (
<button
key={cat}
className={`badge ${activeCategory === cat ? "" : "surface-muted"}`}
onClick={() => setActiveCategory(cat)}
aria-label={`Filter: ${CATEGORY_LABELS[cat]}`}
>
{CATEGORY_LABELS[cat]}
</button>
))}
</div>
{/* Template grid */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "var(--nl-space-2)" }}>
{filtered.map((t) => (
<button
key={t.id}
className="surface-muted"
disabled={runningId !== null}
onClick={() => void handleRun(t)}
aria-label={`Run: ${t.name}`}
style={{
padding: "var(--nl-space-3)",
textAlign: "left",
cursor: runningId ? "wait" : "pointer",
opacity: runningId && runningId !== t.id ? 0.5 : 1,
display: "grid",
gap: 2,
}}
>
<span style={{ fontWeight: 600, fontSize: "var(--nl-fs-sm)", display: "flex", alignItems: "center", gap: 4 }}>
{runningId === t.id ? <Loader2 size={12} className="animate-spin" /> : <FileText size={12} />}
{t.name}
</span>
<span style={{ fontSize: "var(--nl-fs-xs)", color: "var(--nl-text-secondary)" }}>{t.description}</span>
</button>
))}
</div>
{/* Result display */}
{result && (
<div className="surface-muted" style={{ padding: "var(--nl-space-4)", display: "grid", gap: "var(--nl-space-3)" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<strong style={{ fontSize: "var(--nl-fs-sm)" }}>Result</strong>
<div style={{ display: "flex", gap: "var(--nl-space-2)" }}>
<button
className="btn btn-secondary"
style={{ fontSize: "var(--nl-fs-xs)", padding: "2px 8px" }}
onClick={() => { void navigator.clipboard.writeText(result.content); toast.success("Copied"); }}
aria-label="Copy result"
>
<Copy size={12} /> Copy
</button>
<button
className="btn btn-secondary"
style={{ fontSize: "var(--nl-fs-xs)", padding: "2px 8px" }}
onClick={() => setResult(null)}
aria-label="Dismiss result"
>
Dismiss
</button>
</div>
</div>
<div style={{ whiteSpace: "pre-wrap", fontSize: "var(--nl-fs-sm)", maxHeight: 300, overflowY: "auto" }}>
{result.content}
</div>
{result.usage && (
<div style={{ fontSize: "var(--nl-fs-xs)", color: "var(--nl-text-secondary)" }}>
{result.model} &middot; {result.usage.totalTokens} tokens
</div>
)}
</div>
)}
</section>
);
}

View File

@ -0,0 +1,127 @@
"use client";
import { createNotesApiClient } from "@/lib/api-helpers";
import type {
PromptTemplate,
RunPromptInput,
RunPromptOutput,
SimilarNote,
KnowledgeGap,
} from "@/lib/types";
// ── Template CRUD ─────────────────────────────────────────────
export async function listPromptTemplates(): Promise<PromptTemplate[]> {
const api = createNotesApiClient();
const res = await api.fetch<{ items: PromptTemplate[] }>("/note-prompts");
return res.items;
}
export async function getPromptTemplate(id: string): Promise<PromptTemplate> {
const api = createNotesApiClient();
return api.fetch<PromptTemplate>(`/note-prompts/${encodeURIComponent(id)}`);
}
export async function createPromptTemplate(
input: Omit<PromptTemplate, "id" | "builtIn">,
): Promise<PromptTemplate> {
const api = createNotesApiClient();
return api.fetch<PromptTemplate>("/note-prompts", {
method: "POST",
body: JSON.stringify(input),
});
}
export async function deletePromptTemplate(id: string): Promise<void> {
const api = createNotesApiClient();
await api.fetch(`/note-prompts/${encodeURIComponent(id)}`, { method: "DELETE" });
}
// ── Run prompts ───────────────────────────────────────────────
export async function runPrompt(input: RunPromptInput): Promise<RunPromptOutput> {
const api = createNotesApiClient();
return api.fetch<RunPromptOutput>("/note-prompts/run", {
method: "POST",
body: JSON.stringify(input),
});
}
// ── Intelligence endpoints ────────────────────────────────────
export async function suggestTags(
noteId: string,
workspaceId: string,
): Promise<string[]> {
const api = createNotesApiClient();
const res = await api.fetch<{ tags: string[] }>(
`/notes/${encodeURIComponent(noteId)}/suggest-tags`,
{ method: "POST", body: JSON.stringify({ workspaceId }) },
);
return res.tags;
}
export async function checkDuplicates(
noteId: string,
workspaceId: string,
threshold = 0.85,
): Promise<SimilarNote[]> {
const api = createNotesApiClient();
const res = await api.fetch<{ duplicates: SimilarNote[] }>(
`/notes/${encodeURIComponent(noteId)}/check-duplicates`,
{ method: "POST", body: JSON.stringify({ workspaceId, threshold }) },
);
return res.duplicates;
}
export async function suggestLinks(
noteId: string,
workspaceId: string,
threshold = 0.6,
): Promise<SimilarNote[]> {
const api = createNotesApiClient();
const res = await api.fetch<{ suggestions: SimilarNote[] }>(
`/notes/${encodeURIComponent(noteId)}/suggest-links`,
{ method: "POST", body: JSON.stringify({ workspaceId, threshold }) },
);
return res.suggestions;
}
export async function getReadingTime(
noteId: string,
workspaceId: string,
): Promise<{ wordCount: number; readingTimeMinutes: number }> {
const api = createNotesApiClient();
return api.fetch(`/notes/${encodeURIComponent(noteId)}/reading-time?workspaceId=${encodeURIComponent(workspaceId)}`);
}
export async function compareNotes(
noteIds: string[],
workspaceId: string,
): Promise<RunPromptOutput> {
const api = createNotesApiClient();
return api.fetch<RunPromptOutput>("/notes/compare", {
method: "POST",
body: JSON.stringify({ noteIds, workspaceId }),
});
}
export async function mergeNotes(
noteIds: string[],
workspaceId: string,
): Promise<RunPromptOutput> {
const api = createNotesApiClient();
return api.fetch<RunPromptOutput>("/notes/merge", {
method: "POST",
body: JSON.stringify({ noteIds, workspaceId }),
});
}
export async function getKnowledgeGaps(
workspaceId: string,
): Promise<{ gaps: KnowledgeGap[]; topicMap: Record<string, number> }> {
const api = createNotesApiClient();
return api.fetch(`/workspaces/${encodeURIComponent(workspaceId)}/knowledge-gaps`, {
method: "POST",
});
}

View File

@ -175,6 +175,53 @@ export type NoteRelationshipDoc = {
relationshipType: string;
};
// ── Smart Actions / Prompt types ──
export type PromptCategory = "transform" | "extract" | "generate" | "analysis" | "vision" | "export" | "custom";
export type PromptInputType = "text" | "image" | "text+image" | "multi-note";
export type PromptOutputType = "new_note" | "artifact" | "update_note";
export interface PromptTemplate {
id: string;
slug: string;
name: string;
description: string;
category: PromptCategory;
inputType: PromptInputType;
outputType: PromptOutputType;
builtIn: boolean;
systemPrompt?: string;
userPromptTemplate?: string;
}
export interface RunPromptInput {
templateId: string;
noteId: string;
workspaceId: string;
imageUrl?: string;
parameters?: Record<string, string>;
}
export interface RunPromptOutput {
content: string;
templateSlug: string;
outputType: PromptOutputType;
model?: string;
usage?: { promptTokens: number; completionTokens: number; totalTokens: number };
}
export interface SimilarNote {
id: string;
title: string;
similarity: number;
}
export interface KnowledgeGap {
topic: string;
description: string;
suggestedTitle: string;
}
export type NoteListResponse = { items: NoteDoc[] };
export type WorkspaceListResponse = { items: WorkspaceDoc[] };
export type NoteTaskListResponse = { items: NoteTaskDoc[] };