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:
parent
fe3b0f9b3e
commit
564e8f72dc
@ -11,6 +11,7 @@ import { TaskReviewPanel } from "@/components/TaskReviewPanel";
|
|||||||
import { ExtractedTasksPanel } from "@/components/ExtractedTasksPanel";
|
import { ExtractedTasksPanel } from "@/components/ExtractedTasksPanel";
|
||||||
import { ArtifactPanel } from "@/components/ArtifactPanel";
|
import { ArtifactPanel } from "@/components/ArtifactPanel";
|
||||||
import { AgentTimeline } from "@/components/AgentTimeline";
|
import { AgentTimeline } from "@/components/AgentTimeline";
|
||||||
|
import { SmartActionsPanel } from "@/components/SmartActionsPanel";
|
||||||
import { LinkNoteModal } from "@/components/LinkNoteModal";
|
import { LinkNoteModal } from "@/components/LinkNoteModal";
|
||||||
import {
|
import {
|
||||||
archiveNote,
|
archiveNote,
|
||||||
@ -268,6 +269,13 @@ export default function NoteDetailPage() {
|
|||||||
|
|
||||||
<aside style={{ display: "grid", gap: "var(--nl-space-4)" }}>
|
<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}
|
{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} />
|
<MetadataPanel note={note} />
|
||||||
<NoteVersionsPanel noteId={note.id} workspaceId={note.workspaceId} />
|
<NoteVersionsPanel noteId={note.id} workspaceId={note.workspaceId} />
|
||||||
<LinkedNotesPanel linkedNotes={note.linkedNotes} />
|
<LinkedNotesPanel linkedNotes={note.linkedNotes} />
|
||||||
|
|||||||
175
web/src/app/(app)/prompts/page.tsx
Normal file
175
web/src/app/(app)/prompts/page.tsx
Normal 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>·</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>·</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -10,6 +10,7 @@ const navItems: { href: string; label: string; icon: typeof House; flag?: string
|
|||||||
{ href: "/dashboard", label: "Dashboard", icon: House },
|
{ href: "/dashboard", label: "Dashboard", icon: House },
|
||||||
{ href: "/workspaces", label: "Workspaces", icon: FolderKanban },
|
{ href: "/workspaces", label: "Workspaces", icon: FolderKanban },
|
||||||
{ href: "/reviews", label: "Reviews", icon: ShieldCheck, flag: "mcp_tools_enabled" },
|
{ href: "/reviews", label: "Reviews", icon: ShieldCheck, flag: "mcp_tools_enabled" },
|
||||||
|
{ href: "/prompts", label: "Prompts", icon: Sparkles },
|
||||||
{ href: "/search", label: "Search", icon: Search },
|
{ href: "/search", label: "Search", icon: Search },
|
||||||
{ href: "/chat", label: "Workspace chat", icon: MessageCircle },
|
{ href: "/chat", label: "Workspace chat", icon: MessageCircle },
|
||||||
{ href: "/settings", label: "Settings", icon: Settings },
|
{ href: "/settings", label: "Settings", icon: Settings },
|
||||||
|
|||||||
198
web/src/components/SmartActionsPanel.tsx
Normal file
198
web/src/components/SmartActionsPanel.tsx
Normal 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 · {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} · {result.usage.totalTokens} tokens
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
127
web/src/lib/prompt-client.ts
Normal file
127
web/src/lib/prompt-client.ts
Normal 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",
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -175,6 +175,53 @@ export type NoteRelationshipDoc = {
|
|||||||
relationshipType: string;
|
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 NoteListResponse = { items: NoteDoc[] };
|
||||||
export type WorkspaceListResponse = { items: WorkspaceDoc[] };
|
export type WorkspaceListResponse = { items: WorkspaceDoc[] };
|
||||||
export type NoteTaskListResponse = { items: NoteTaskDoc[] };
|
export type NoteTaskListResponse = { items: NoteTaskDoc[] };
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user