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 { 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} />
|
||||
|
||||
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: "/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 },
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
// ── 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[] };
|
||||
|
||||
Loading…
Reference in New Issue
Block a user