feat(smart-actions): add run-stream SSE, history endpoint, weekly-digest template, web client functions (G1-G5)
This commit is contained in:
parent
4bb2d84152
commit
093da76eee
16
backend/src/lib/reading-time.ts
Normal file
16
backend/src/lib/reading-time.ts
Normal 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 };
|
||||
}
|
||||
@ -13,6 +13,7 @@ export const NOTES_MCP_TOOL_NAMES = {
|
||||
suggestTags: 'notes.intelligence.suggest_tags',
|
||||
checkDuplicates: 'notes.intelligence.check_duplicates',
|
||||
suggestLinks: 'notes.intelligence.suggest_links',
|
||||
runPrompt: 'notes.prompts.run',
|
||||
} as const;
|
||||
|
||||
export const NoteToolRoleSchema = z.enum(['viewer', 'admin', 'super_admin']);
|
||||
@ -334,6 +335,38 @@ export const SmartActionMcpToolDefinitions = {
|
||||
},
|
||||
};
|
||||
|
||||
// ── Run Prompt MCP tool schemas ───────────────────────────────
|
||||
|
||||
export const RunPromptToolInputSchema = z.object({
|
||||
noteId: z.string().min(1).max(128),
|
||||
workspaceId: z.string().min(1).max(128),
|
||||
templateId: z.string().min(1).max(128).optional(),
|
||||
inlinePrompt: z.string().max(4000).optional(),
|
||||
parameters: z.record(z.string()).optional(),
|
||||
additionalNoteIds: z.array(z.string().min(1).max(128)).max(10).optional(),
|
||||
dryRun: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const RunPromptToolOutputSchema = z.object({
|
||||
content: z.string(),
|
||||
model: z.string(),
|
||||
resultNoteId: z.string().nullable(),
|
||||
resultArtifactId: z.string().nullable(),
|
||||
approvalState: z.string().optional(),
|
||||
});
|
||||
|
||||
export const RunPromptMcpToolDefinition = {
|
||||
name: NOTES_MCP_TOOL_NAMES.runPrompt,
|
||||
description: 'Run a prompt template (or inline prompt) on a note. Can merge/compare multiple notes, chain from previous results, and produce new notes or artifacts.',
|
||||
requiredRole: 'admin' as const,
|
||||
inputSchema: RunPromptToolInputSchema,
|
||||
outputSchema: RunPromptToolOutputSchema,
|
||||
readOnly: false,
|
||||
};
|
||||
|
||||
export type RunPromptToolInput = z.infer<typeof RunPromptToolInputSchema>;
|
||||
export type RunPromptToolOutput = z.infer<typeof RunPromptToolOutputSchema>;
|
||||
|
||||
export type SuggestTagsToolInput = z.infer<typeof SuggestTagsToolInputSchema>;
|
||||
export type SuggestTagsToolOutput = z.infer<typeof SuggestTagsToolOutputSchema>;
|
||||
export type CheckDuplicatesToolInput = z.infer<typeof CheckDuplicatesToolInputSchema>;
|
||||
|
||||
@ -50,6 +50,7 @@ describe('note executable MCP tools', () => {
|
||||
NOTES_MCP_TOOL_NAMES.suggestTags,
|
||||
NOTES_MCP_TOOL_NAMES.checkDuplicates,
|
||||
NOTES_MCP_TOOL_NAMES.suggestLinks,
|
||||
NOTES_MCP_TOOL_NAMES.runPrompt,
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@ -26,6 +26,9 @@ import {
|
||||
CheckDuplicatesToolOutputSchema,
|
||||
SuggestLinksToolOutputSchema,
|
||||
SmartActionMcpToolDefinitions,
|
||||
RunPromptMcpToolDefinition,
|
||||
RunPromptToolOutputSchema,
|
||||
type RunPromptToolInput,
|
||||
type AttachArtifactToolInput,
|
||||
type CreateNoteDraftToolInput,
|
||||
type ExtractTasksToolInput,
|
||||
@ -518,6 +521,63 @@ async function executeAttachArtifact(args: AttachArtifactToolInput, req: NotesMc
|
||||
});
|
||||
}
|
||||
|
||||
// ── Run Prompt MCP tool implementation ────────────────────────
|
||||
|
||||
async function executeRunPrompt(args: RunPromptToolInput, req: NotesMcpRequest) {
|
||||
const userId = requireUserId(req);
|
||||
const note = await getNote(args.noteId, args.workspaceId);
|
||||
if (!note || note.userId !== userId || note.productId !== PRODUCT_ID) {
|
||||
throw new Error('Note not found');
|
||||
}
|
||||
|
||||
const { executePrompt } = await import('../modules/note-prompts/runner.js');
|
||||
const promptRepo = await import('../modules/note-prompts/repository.js');
|
||||
|
||||
// Resolve template: by templateId or use inline prompt with a synthetic template
|
||||
let template;
|
||||
if (args.templateId) {
|
||||
template = await promptRepo.getPromptTemplate(args.templateId, userId);
|
||||
if (!template) throw new Error(`Template "${args.templateId}" not found`);
|
||||
} else if (args.inlinePrompt) {
|
||||
template = {
|
||||
id: 'inline',
|
||||
productId: PRODUCT_ID,
|
||||
userId,
|
||||
slug: 'inline-mcp',
|
||||
name: 'Inline MCP Prompt',
|
||||
description: '',
|
||||
systemPrompt: 'You are a helpful note assistant.',
|
||||
userPromptTemplate: args.inlinePrompt,
|
||||
inputType: 'text' as const,
|
||||
outputType: 'new_note' as const,
|
||||
category: 'transform' as const,
|
||||
isBuiltin: false,
|
||||
requiresApproval: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
} else {
|
||||
throw new Error('Either templateId or inlinePrompt is required');
|
||||
}
|
||||
|
||||
const input = {
|
||||
templateId: template.slug,
|
||||
noteId: args.noteId,
|
||||
workspaceId: args.workspaceId,
|
||||
variables: args.parameters,
|
||||
};
|
||||
|
||||
const result = await executePrompt(template, input, note.body ?? '');
|
||||
|
||||
return RunPromptToolOutputSchema.parse({
|
||||
content: result.content,
|
||||
model: result.model,
|
||||
resultNoteId: result.createdNoteId ?? null,
|
||||
resultArtifactId: result.createdArtifactId ?? null,
|
||||
approvalState: result.approvalState,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Smart Action MCP tool implementations ─────────────────────
|
||||
|
||||
async function executeSuggestTags(args: SuggestTagsToolInput, req: NotesMcpRequest) {
|
||||
@ -639,6 +699,7 @@ export const NotesExecutableMcpTools: Array<
|
||||
| NotesMcpTool<SuggestTagsToolInput>
|
||||
| NotesMcpTool<CheckDuplicatesToolInput>
|
||||
| NotesMcpTool<SuggestLinksToolInput>
|
||||
| NotesMcpTool<RunPromptToolInput>
|
||||
> = [
|
||||
{
|
||||
...NotesMcpToolDefinitions.list,
|
||||
@ -684,6 +745,10 @@ export const NotesExecutableMcpTools: Array<
|
||||
...SmartActionMcpToolDefinitions.suggestLinks,
|
||||
execute: executeSuggestLinks,
|
||||
},
|
||||
{
|
||||
...RunPromptMcpToolDefinition,
|
||||
execute: executeRunPrompt,
|
||||
},
|
||||
];
|
||||
|
||||
export function getNotesExecutableMcpTool(name: string) {
|
||||
|
||||
@ -272,9 +272,9 @@ describe('reading-time', () => {
|
||||
});
|
||||
|
||||
describe('seed', () => {
|
||||
it('getBuiltinTemplates returns 20 templates', () => {
|
||||
it('getBuiltinTemplates returns 21 templates', () => {
|
||||
const templates = getBuiltinTemplates();
|
||||
expect(templates.length).toBe(20);
|
||||
expect(templates.length).toBe(21);
|
||||
expect(templates.every((t) => t.isBuiltin)).toBe(true);
|
||||
expect(templates.every((t) => t.id.startsWith('builtin-'))).toBe(true);
|
||||
});
|
||||
|
||||
@ -9,6 +9,7 @@ import { BadRequestError, NotFoundError } from '@bytelyst/errors';
|
||||
import { isFeatureEnabled } from '../../lib/feature-flags.js';
|
||||
import { trackEvent } from '../../lib/telemetry.js';
|
||||
import { embedText, cosineSimilarity, stripHtmlForEmbedding } from '../../lib/embeddings.js';
|
||||
import { estimateReadingTime } from '../../lib/reading-time.js';
|
||||
import { llm } from '../../lib/llm.js';
|
||||
import {
|
||||
CreatePromptTemplateSchema,
|
||||
@ -108,6 +109,91 @@ export async function notePromptRoutes(app: FastifyInstance): Promise<void> {
|
||||
return result;
|
||||
});
|
||||
|
||||
// ── Run a prompt template with SSE streaming ────────────────────
|
||||
app.post('/note-prompts/run-stream', async (req, reply) => {
|
||||
if (!isFeatureEnabled('notelett_smart_actions_enabled')) throw new BadRequestError('Smart Actions are disabled');
|
||||
const userId = getUserId(req);
|
||||
const productId = getRequestProductId(req);
|
||||
const input = RunPromptSchema.parse(req.body);
|
||||
|
||||
let template = await repo.getPromptTemplate(input.templateId, userId);
|
||||
if (!template) {
|
||||
template = await repo.getPromptTemplate(input.templateId, '__builtin__');
|
||||
}
|
||||
if (!template) {
|
||||
template = await repo.getPromptTemplateBySlug(input.templateId, userId);
|
||||
}
|
||||
if (!template) throw new NotFoundError('Prompt template not found');
|
||||
|
||||
if (
|
||||
(template.inputType === 'image' || template.inputType === 'text+image') &&
|
||||
!input.imageUrl
|
||||
) {
|
||||
throw new BadRequestError('This prompt requires an image URL');
|
||||
}
|
||||
|
||||
const note = await noteRepo.getNote(input.noteId, input.workspaceId);
|
||||
if (!note || note.userId !== userId || note.productId !== productId) {
|
||||
throw new NotFoundError('Note not found');
|
||||
}
|
||||
|
||||
const noteBody = note.body?.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim() ?? '';
|
||||
|
||||
// SSE headers
|
||||
reply.raw.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
});
|
||||
void reply.hijack();
|
||||
|
||||
try {
|
||||
const result = await executePrompt(template, input, noteBody);
|
||||
// Stream the result as a series of SSE events: tokens then done
|
||||
const chunks = result.content.match(/.{1,80}/g) ?? [result.content];
|
||||
for (const chunk of chunks) {
|
||||
reply.raw.write(`data: ${JSON.stringify({ type: 'token', content: chunk })}\n\n`);
|
||||
}
|
||||
reply.raw.write(`data: ${JSON.stringify({ type: 'done', templateSlug: result.templateSlug, outputType: result.outputType, model: result.model, usage: result.usage, approvalState: result.approvalState })}\n\n`);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Stream failed';
|
||||
reply.raw.write(`data: ${JSON.stringify({ type: 'error', message })}\n\n`);
|
||||
}
|
||||
reply.raw.end();
|
||||
});
|
||||
|
||||
// ── Prompt run history ──────────────────────────────────────────
|
||||
app.get('/note-prompts/history', async (req) => {
|
||||
const userId = getUserId(req);
|
||||
const productId = getRequestProductId(req);
|
||||
const { workspaceId, limit: limitStr } = req.query as { workspaceId: string; limit?: string };
|
||||
if (!workspaceId) throw new BadRequestError('workspaceId query param required');
|
||||
const limit = Math.min(Math.max(parseInt(limitStr || '20', 10) || 20, 1), 100);
|
||||
|
||||
// History is derived from agent actions of type 'smart_action'
|
||||
const { listNoteAgentActions } = await import('../note-agent-actions/repository.js');
|
||||
const { items, total } = await listNoteAgentActions(userId, productId, {
|
||||
workspaceId,
|
||||
actionType: 'smart_action',
|
||||
limit,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
return {
|
||||
items: items.map((a) => ({
|
||||
id: a.id,
|
||||
noteId: a.noteId,
|
||||
workspaceId: a.workspaceId,
|
||||
toolName: a.toolName,
|
||||
state: a.state,
|
||||
reason: a.reason,
|
||||
afterSummary: a.afterSummary,
|
||||
createdAt: a.createdAt,
|
||||
})),
|
||||
total,
|
||||
};
|
||||
});
|
||||
|
||||
// ── Reading time estimate ───────────────────────────────────────
|
||||
app.get('/notes/:id/reading-time', async (req) => {
|
||||
const userId = getUserId(req);
|
||||
@ -122,11 +208,9 @@ export async function notePromptRoutes(app: FastifyInstance): Promise<void> {
|
||||
throw new NotFoundError('Note not found');
|
||||
}
|
||||
|
||||
const plainText = (note.body ?? '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
const wordCount = plainText.split(/\s+/).filter(Boolean).length;
|
||||
const readingTimeMinutes = Math.max(1, Math.ceil(wordCount / 238));
|
||||
const { words, minutes } = estimateReadingTime(note.body ?? '');
|
||||
|
||||
return { wordCount, readingTimeMinutes };
|
||||
return { wordCount: words, readingTimeMinutes: minutes };
|
||||
});
|
||||
|
||||
// ── Suggest tags via LLM (F5) ──────────────────────────────────
|
||||
|
||||
@ -230,6 +230,19 @@ const TEMPLATES: SeedTemplate[] = [
|
||||
userPromptTemplate: 'Convert this note into a social media post:\n\n{{noteBody}}',
|
||||
maxTokens: 512,
|
||||
},
|
||||
// ── Scheduled / System ───────────────────────────
|
||||
{
|
||||
slug: 'weekly-digest',
|
||||
name: 'Weekly Digest',
|
||||
description: 'Generate a weekly workspace digest summarizing new and modified notes',
|
||||
category: 'analyze',
|
||||
inputType: 'text',
|
||||
outputType: 'new_note',
|
||||
systemPrompt: 'You produce a weekly digest for a workspace. Summarize key themes, list new notes, highlight the most active areas, and note any patterns. Use markdown headings.',
|
||||
userPromptTemplate: 'Create a weekly digest for this workspace. Here are the notes created or modified this week:\n\n{{noteBody}}',
|
||||
temperature: 0.4,
|
||||
maxTokens: 2048,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
180
docs/SMART_ACTIONS_USER_GUIDE.md
Normal file
180
docs/SMART_ACTIONS_USER_GUIDE.md
Normal 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 |
|
||||
107
web/src/app/(app)/workspaces/[id]/gaps/page.tsx
Normal file
107
web/src/app/(app)/workspaces/[id]/gaps/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
107
web/src/components/PromptResultView.tsx
Normal file
107
web/src/components/PromptResultView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
151
web/src/components/PromptTemplateEditor.tsx
Normal file
151
web/src/components/PromptTemplateEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
150
web/src/components/RunPromptModal.tsx
Normal file
150
web/src/components/RunPromptModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 ────────────────────────────────────
|
||||
|
||||
export async function suggestTags(
|
||||
|
||||
@ -179,7 +179,7 @@ export type NoteRelationshipDoc = {
|
||||
|
||||
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 type PromptOutputType = "new_note" | "artifact" | "update_note" | "replace" | "clipboard";
|
||||
|
||||
export interface PromptTemplate {
|
||||
id: string;
|
||||
@ -208,6 +208,9 @@ export interface RunPromptOutput {
|
||||
outputType: PromptOutputType;
|
||||
model?: string;
|
||||
usage?: { promptTokens: number; completionTokens: number; totalTokens: number };
|
||||
approvalState?: string;
|
||||
resultNoteId?: string | null;
|
||||
resultArtifactId?: string | null;
|
||||
}
|
||||
|
||||
export interface SimilarNote {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user