feat(backend): add note-prompts module with Smart Actions LLM integration
- Add @bytelyst/llm dependency (file: ref) + llm.ts singleton wrapper - Add LLM env vars to config (LLM_PROVIDER, LLM_DEFAULT_MODEL, LLM_VISION_MODEL, LLM_EMBEDDING_MODEL) - Create note-prompts module: types, repository, runner, routes, seed (20 built-in templates) - Built-in templates: 8 transform, 3 extract, 3 generate, 2 analyze, 2 vision, 2 export - Prompt runner supports text, image, and text+image inputs via @bytelyst/llm vision - Upgrade copilot-transform.ts to use @bytelyst/llm directly (with local heuristic fallback) - Add reading-time endpoint (GET /api/notes/:id/reading-time) - Extend agent-action types with smart_action and auto_enrich - Add note_prompts Cosmos container to cosmos-init - Register notePromptRoutes in server.ts - 15 new tests (CRUD, run, slug resolution, seed validation, reading-time)
This commit is contained in:
parent
7ee2151f17
commit
9e3a7206b9
@ -28,6 +28,7 @@
|
|||||||
"@bytelyst/fastify-auth": "^0.1.0",
|
"@bytelyst/fastify-auth": "^0.1.0",
|
||||||
"@bytelyst/fastify-core": "^0.1.0",
|
"@bytelyst/fastify-core": "^0.1.0",
|
||||||
"@bytelyst/field-encrypt": "^0.1.0",
|
"@bytelyst/field-encrypt": "^0.1.0",
|
||||||
|
"@bytelyst/llm": "file:../../learning_ai_common_plat/packages/llm",
|
||||||
"@bytelyst/logger": "^0.1.0",
|
"@bytelyst/logger": "^0.1.0",
|
||||||
"fastify": "5.7.4",
|
"fastify": "5.7.4",
|
||||||
"jose": "^6.0.8",
|
"jose": "^6.0.8",
|
||||||
|
|||||||
@ -14,6 +14,11 @@ const envSchema = baseBackendConfigSchema.extend({
|
|||||||
MCP_SERVER_URL: z.string().default('http://localhost:4007'),
|
MCP_SERVER_URL: z.string().default('http://localhost:4007'),
|
||||||
TELEMETRY_ENABLED: z.coerce.boolean().default(false),
|
TELEMETRY_ENABLED: z.coerce.boolean().default(false),
|
||||||
FEATURE_FLAGS_ENABLED: z.coerce.boolean().default(false),
|
FEATURE_FLAGS_ENABLED: z.coerce.boolean().default(false),
|
||||||
|
// ── LLM (@bytelyst/llm) ──
|
||||||
|
LLM_PROVIDER: z.enum(['azure', 'openai', 'mock']).default('mock'),
|
||||||
|
LLM_DEFAULT_MODEL: z.string().default('gpt-4o-mini'),
|
||||||
|
LLM_VISION_MODEL: z.string().default('gpt-4o'),
|
||||||
|
LLM_EMBEDDING_MODEL: z.string().default('text-embedding-3-small'),
|
||||||
// ── Field Encryption (@bytelyst/field-encrypt) ──
|
// ── Field Encryption (@bytelyst/field-encrypt) ──
|
||||||
FIELD_ENCRYPT_ENABLED: z.coerce.boolean().default(true),
|
FIELD_ENCRYPT_ENABLED: z.coerce.boolean().default(true),
|
||||||
FIELD_ENCRYPT_KEY_PROVIDER: z.enum(['akv', 'env', 'memory']).default('memory'),
|
FIELD_ENCRYPT_KEY_PROVIDER: z.enum(['akv', 'env', 'memory']).default('memory'),
|
||||||
|
|||||||
@ -1,7 +1,20 @@
|
|||||||
import { extractFromText } from './extraction-client.js';
|
/**
|
||||||
|
* Copilot text transforms — powered by @bytelyst/llm.
|
||||||
|
*
|
||||||
|
* Falls back to local heuristics if LLM is unavailable.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { llm } from './llm.js';
|
||||||
|
|
||||||
export type CopilotAction = 'shorten' | 'expand' | 'bulletize' | 'grammar';
|
export type CopilotAction = 'shorten' | 'expand' | 'bulletize' | 'grammar';
|
||||||
|
|
||||||
|
const SYSTEM_PROMPTS: Record<CopilotAction, string> = {
|
||||||
|
shorten: 'Condense the text to about half its length while preserving key points. Return only the shortened text.',
|
||||||
|
expand: 'Expand the text with more detail and examples. Return only the expanded text.',
|
||||||
|
bulletize: 'Convert the text into concise bullet points. Return only the bullet points.',
|
||||||
|
grammar: 'Fix grammar, spelling, and punctuation. Preserve original meaning and tone. Return only the corrected text.',
|
||||||
|
};
|
||||||
|
|
||||||
function fallbackTransform(action: CopilotAction, text: string): string {
|
function fallbackTransform(action: CopilotAction, text: string): string {
|
||||||
const lines = text.split(/\n/).map((l) => l.trim()).filter(Boolean);
|
const lines = text.split(/\n/).map((l) => l.trim()).filter(Boolean);
|
||||||
switch (action) {
|
switch (action) {
|
||||||
@ -21,30 +34,46 @@ function fallbackTransform(action: CopilotAction, text: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function runCopilotTransform(action: CopilotAction, text: string): Promise<string> {
|
export async function runCopilotTransform(action: CopilotAction, text: string): Promise<string> {
|
||||||
const prompt = `Transform the following text with action "${action}". Return only the transformed text, no preamble.\n\n---\n${text}`;
|
const provider = llm();
|
||||||
|
if (!provider.isConfigured()) {
|
||||||
|
return fallbackTransform(action, text);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await extractFromText(prompt, 'copilot_transform');
|
const result = await provider.chatCompletion({
|
||||||
const out = result.summary?.trim();
|
messages: [
|
||||||
if (out && out.length > 0) {
|
{ role: 'system', content: SYSTEM_PROMPTS[action] },
|
||||||
return out;
|
{ role: 'user', content: text },
|
||||||
}
|
],
|
||||||
|
temperature: 0.3,
|
||||||
|
maxTokens: 4096,
|
||||||
|
});
|
||||||
|
const out = result.content.trim();
|
||||||
|
if (out.length > 0) return out;
|
||||||
} catch {
|
} catch {
|
||||||
// fall through
|
// fall through to local heuristics
|
||||||
}
|
}
|
||||||
return fallbackTransform(action, text);
|
return fallbackTransform(action, text);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function suggestTitleFromBody(body: string): Promise<string> {
|
export async function suggestTitleFromBody(body: string): Promise<string> {
|
||||||
const plain = body.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
const plain = body.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||||
|
const provider = llm();
|
||||||
|
if (!provider.isConfigured()) {
|
||||||
|
return plain.split(/[.!?]/)[0]?.trim().slice(0, 80) || 'Untitled note';
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await extractFromText(
|
const result = await provider.chatCompletion({
|
||||||
`Propose a short note title (max 8 words) for this content. Reply with the title only.\n\n${plain.slice(0, 4000)}`,
|
messages: [
|
||||||
'title_suggestion',
|
{ role: 'system', content: 'Suggest a concise, descriptive title (max 8 words). Return only the title, no quotes.' },
|
||||||
);
|
{ role: 'user', content: plain.slice(0, 4000) },
|
||||||
const t = result.summary?.trim();
|
],
|
||||||
if (t && t.length > 0 && t.length < 500) {
|
temperature: 0.6,
|
||||||
return t;
|
maxTokens: 64,
|
||||||
}
|
});
|
||||||
|
const t = result.content.trim();
|
||||||
|
if (t.length > 0 && t.length < 500) return t;
|
||||||
} catch {
|
} catch {
|
||||||
// fall through
|
// fall through
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ const CONTAINER_DEFS: Record<string, ContainerConfig> = {
|
|||||||
note_artifacts: { partitionKeyPath: '/workspaceId' },
|
note_artifacts: { partitionKeyPath: '/workspaceId' },
|
||||||
note_agent_actions: { partitionKeyPath: '/workspaceId' },
|
note_agent_actions: { partitionKeyPath: '/workspaceId' },
|
||||||
saved_views: { partitionKeyPath: '/userId' },
|
saved_views: { partitionKeyPath: '/userId' },
|
||||||
|
note_prompts: { partitionKeyPath: '/userId' },
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function initCosmosIfNeeded(): Promise<void> {
|
export async function initCosmosIfNeeded(): Promise<void> {
|
||||||
|
|||||||
36
backend/src/lib/llm.ts
Normal file
36
backend/src/lib/llm.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* LLM singleton for NoteLett backend.
|
||||||
|
*
|
||||||
|
* Wraps @bytelyst/llm with lazy initialization.
|
||||||
|
* Provider is auto-detected from env vars (LLM_PROVIDER, OPENAI_API_KEY, etc.).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getLLM, createLLMProvider, setLLM } from '@bytelyst/llm';
|
||||||
|
import type { LLMProvider } from '@bytelyst/llm';
|
||||||
|
|
||||||
|
let initialized = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the LLM provider singleton.
|
||||||
|
* Safe to call multiple times — only initializes once.
|
||||||
|
*/
|
||||||
|
export function initLLM(): LLMProvider {
|
||||||
|
if (!initialized) {
|
||||||
|
const providerType = (process.env.LLM_PROVIDER || 'mock') as 'azure' | 'openai' | 'mock';
|
||||||
|
const provider = createLLMProvider(providerType);
|
||||||
|
setLLM(provider);
|
||||||
|
initialized = true;
|
||||||
|
}
|
||||||
|
return getLLM();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the initialized LLM provider.
|
||||||
|
* Calls initLLM() if not yet initialized.
|
||||||
|
*/
|
||||||
|
export function llm(): LLMProvider {
|
||||||
|
if (!initialized) {
|
||||||
|
return initLLM();
|
||||||
|
}
|
||||||
|
return getLLM();
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
export const NOTE_AGENT_ACTION_TYPES = ['create', 'update', 'summarize', 'extract_tasks', 'attach_citation'] as const;
|
export const NOTE_AGENT_ACTION_TYPES = ['create', 'update', 'summarize', 'extract_tasks', 'attach_citation', 'smart_action', 'auto_enrich'] as const;
|
||||||
export type NoteAgentActionType = (typeof NOTE_AGENT_ACTION_TYPES)[number];
|
export type NoteAgentActionType = (typeof NOTE_AGENT_ACTION_TYPES)[number];
|
||||||
|
|
||||||
export const NOTE_AGENT_ACTION_STATES = ['draft', 'proposed', 'approved', 'rejected', 'applied'] as const;
|
export const NOTE_AGENT_ACTION_STATES = ['draft', 'proposed', 'approved', 'rejected', 'applied'] as const;
|
||||||
|
|||||||
293
backend/src/modules/note-prompts/note-prompts.test.ts
Normal file
293
backend/src/modules/note-prompts/note-prompts.test.ts
Normal file
@ -0,0 +1,293 @@
|
|||||||
|
/**
|
||||||
|
* Tests for note-prompts module — CRUD + run + seed + reading-time.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
|
||||||
|
const { extractAuthMock } = vi.hoisted(() => ({
|
||||||
|
extractAuthMock: vi.fn(async () => ({ sub: 'user_1', type: 'access', role: 'editor' })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../lib/auth.js', () => ({ extractAuth: extractAuthMock, requireWriter: extractAuthMock }));
|
||||||
|
vi.mock('../../lib/product-config.js', () => ({
|
||||||
|
PRODUCT_ID: 'notelett',
|
||||||
|
DISPLAY_NAME: 'NoteLett',
|
||||||
|
productConfig: { productId: 'notelett', displayName: 'NoteLett' },
|
||||||
|
}));
|
||||||
|
vi.mock('../../lib/request-context.js', () => ({
|
||||||
|
getUserId: vi.fn(() => 'user_1'),
|
||||||
|
getRequestProductId: vi.fn(() => 'notelett'),
|
||||||
|
}));
|
||||||
|
vi.mock('../../lib/telemetry.js', () => ({ trackEvent: vi.fn() }));
|
||||||
|
vi.mock('../../lib/feature-flags.js', () => ({ isFeatureEnabled: vi.fn(() => true) }));
|
||||||
|
vi.mock('../../lib/field-encrypt.js', () => ({
|
||||||
|
initEncryption: vi.fn(),
|
||||||
|
getEncryptor: vi.fn(() => ({
|
||||||
|
encrypt: vi.fn(async (v: unknown) => v),
|
||||||
|
decrypt: vi.fn(async (v: unknown) => v),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
vi.mock('../../lib/llm.js', () => ({
|
||||||
|
llm: vi.fn(() => ({
|
||||||
|
isConfigured: () => true,
|
||||||
|
chatCompletion: vi.fn(async () => ({
|
||||||
|
content: 'Mock LLM response',
|
||||||
|
model: 'mock-model',
|
||||||
|
usage: { promptTokens: 10, completionTokens: 10, totalTokens: 20 },
|
||||||
|
finishReason: 'stop',
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
initLLM: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock('@bytelyst/llm', () => ({
|
||||||
|
getLLM: vi.fn(),
|
||||||
|
setLLM: vi.fn(),
|
||||||
|
createLLMProvider: vi.fn(),
|
||||||
|
buildVisionMessage: vi.fn((text: string, url: string) => ({
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'text', text }, { type: 'image_url', image_url: { url, detail: 'auto' } }],
|
||||||
|
})),
|
||||||
|
hasVisionContent: vi.fn(() => false),
|
||||||
|
isVisionMessage: vi.fn(() => false),
|
||||||
|
getMessageText: vi.fn((msg: { content: string }) => typeof msg.content === 'string' ? msg.content : ''),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { buildTestApp, resetMemoryDatastore } from '../../test-helpers.js';
|
||||||
|
import { notePromptRoutes } from './routes.js';
|
||||||
|
import { noteRoutes } from '../notes/routes.js';
|
||||||
|
import { getBuiltinTemplates } from './seed.js';
|
||||||
|
import { upsertBuiltinTemplate } from './repository.js';
|
||||||
|
|
||||||
|
let app: FastifyInstance;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
app = await buildTestApp(async (fastify) => {
|
||||||
|
await noteRoutes(fastify);
|
||||||
|
await notePromptRoutes(fastify);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function seedBuiltins() {
|
||||||
|
for (const t of getBuiltinTemplates()) {
|
||||||
|
await upsertBuiltinTemplate(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('note-prompts CRUD', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetMemoryDatastore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /note-prompts — lists builtins', async () => {
|
||||||
|
await seedBuiltins();
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/note-prompts' });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.items.length).toBeGreaterThan(0);
|
||||||
|
expect(body.items.some((t: { isBuiltin: boolean }) => t.isBuiltin)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /note-prompts?category=transform — filters by category', async () => {
|
||||||
|
await seedBuiltins();
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/note-prompts?category=transform' });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.items.every((t: { category: string }) => t.category === 'transform')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /note-prompts — creates custom template', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/note-prompts',
|
||||||
|
payload: {
|
||||||
|
slug: 'my-custom-prompt',
|
||||||
|
name: 'My Custom Prompt',
|
||||||
|
systemPrompt: 'You are a helpful assistant.',
|
||||||
|
userPromptTemplate: 'Do something with: {{noteBody}}',
|
||||||
|
category: 'transform',
|
||||||
|
inputType: 'text',
|
||||||
|
outputType: 'replace',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.slug).toBe('my-custom-prompt');
|
||||||
|
expect(body.isBuiltin).toBe(false);
|
||||||
|
expect(body.productId).toBe('notelett');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PATCH + DELETE custom template lifecycle', async () => {
|
||||||
|
// create
|
||||||
|
const createRes = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/note-prompts',
|
||||||
|
payload: {
|
||||||
|
slug: 'lifecycle-test',
|
||||||
|
name: 'Lifecycle',
|
||||||
|
systemPrompt: 'sys',
|
||||||
|
userPromptTemplate: '{{noteBody}}',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const id = createRes.json().id;
|
||||||
|
|
||||||
|
// update
|
||||||
|
const patchRes = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `/api/note-prompts/${id}`,
|
||||||
|
payload: { name: 'Updated Name' },
|
||||||
|
});
|
||||||
|
expect(patchRes.statusCode).toBe(200);
|
||||||
|
expect(patchRes.json().name).toBe('Updated Name');
|
||||||
|
|
||||||
|
// delete
|
||||||
|
const delRes = await app.inject({ method: 'DELETE', url: `/api/note-prompts/${id}` });
|
||||||
|
expect(delRes.statusCode).toBe(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PATCH builtin returns 404', async () => {
|
||||||
|
await seedBuiltins();
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: '/api/note-prompts/builtin-summarize',
|
||||||
|
payload: { name: 'Hacked' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE builtin returns 404', async () => {
|
||||||
|
await seedBuiltins();
|
||||||
|
const res = await app.inject({ method: 'DELETE', url: '/api/note-prompts/builtin-summarize' });
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('note-prompts run', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetMemoryDatastore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /note-prompts/run — runs builtin template', async () => {
|
||||||
|
await seedBuiltins();
|
||||||
|
// Create a note first
|
||||||
|
const noteRes = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/notes',
|
||||||
|
payload: { id: 'n1', workspaceId: 'ws1', title: 'Test', body: 'Test content about AI.' },
|
||||||
|
});
|
||||||
|
expect(noteRes.statusCode).toBe(201);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/note-prompts/run',
|
||||||
|
payload: { templateId: 'builtin-summarize', noteId: 'n1', workspaceId: 'ws1' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.content).toBeTruthy();
|
||||||
|
expect(body.templateSlug).toBe('summarize');
|
||||||
|
expect(body.outputType).toBe('new_note');
|
||||||
|
expect(body.usage).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /note-prompts/run — resolves by slug', async () => {
|
||||||
|
await seedBuiltins();
|
||||||
|
await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/notes',
|
||||||
|
payload: { id: 'n2', workspaceId: 'ws2', title: 'Test', body: 'Bullet content.' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/note-prompts/run',
|
||||||
|
payload: { templateId: 'bulletize', noteId: 'n2', workspaceId: 'ws2' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json().templateSlug).toBe('bulletize');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /note-prompts/run — 404 for unknown template', async () => {
|
||||||
|
await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/notes',
|
||||||
|
payload: { id: 'n3', workspaceId: 'ws3', title: 'T', body: 'B' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/note-prompts/run',
|
||||||
|
payload: { templateId: 'nonexistent', noteId: 'n3', workspaceId: 'ws3' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /note-prompts/run — 404 for unknown note', async () => {
|
||||||
|
await seedBuiltins();
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/note-prompts/run',
|
||||||
|
payload: { templateId: 'builtin-summarize', noteId: 'nonexistent', workspaceId: 'ws-x' },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('reading-time', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
resetMemoryDatastore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /notes/:id/reading-time — returns word count and reading time', async () => {
|
||||||
|
await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/notes',
|
||||||
|
payload: { id: 'rt1', workspaceId: 'ws-rt', title: 'Long', body: Array(500).fill('word').join(' ') },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/api/notes/rt1/reading-time?workspaceId=ws-rt',
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.wordCount).toBe(500);
|
||||||
|
expect(body.readingTimeMinutes).toBe(3); // ceil(500/238)
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /notes/:id/reading-time — 400 without workspaceId', async () => {
|
||||||
|
await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/notes',
|
||||||
|
payload: { id: 'rt2', workspaceId: 'ws-rt2', title: 'X', body: 'Y' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/notes/rt2/reading-time' });
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('seed', () => {
|
||||||
|
it('getBuiltinTemplates returns 20 templates', () => {
|
||||||
|
const templates = getBuiltinTemplates();
|
||||||
|
expect(templates.length).toBe(20);
|
||||||
|
expect(templates.every((t) => t.isBuiltin)).toBe(true);
|
||||||
|
expect(templates.every((t) => t.id.startsWith('builtin-'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all slugs are unique', () => {
|
||||||
|
const templates = getBuiltinTemplates();
|
||||||
|
const slugs = templates.map((t) => t.slug);
|
||||||
|
expect(new Set(slugs).size).toBe(slugs.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all categories are valid', () => {
|
||||||
|
const validCategories = ['transform', 'extract', 'generate', 'analyze', 'export'];
|
||||||
|
const templates = getBuiltinTemplates();
|
||||||
|
expect(templates.every((t) => validCategories.includes(t.category))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
157
backend/src/modules/note-prompts/repository.ts
Normal file
157
backend/src/modules/note-prompts/repository.ts
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* Note-prompts repository — CRUD for PromptTemplateDoc.
|
||||||
|
*
|
||||||
|
* Uses @bytelyst/datastore DocumentCollection API:
|
||||||
|
* findById, findMany, create, upsert, delete, count
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getCollection } from '../../lib/datastore.js';
|
||||||
|
import { PRODUCT_ID } from '../../lib/product-config.js';
|
||||||
|
import type { FilterMap } from '@bytelyst/datastore';
|
||||||
|
import type {
|
||||||
|
PromptTemplateDoc,
|
||||||
|
CreatePromptTemplateInput,
|
||||||
|
UpdatePromptTemplateInput,
|
||||||
|
ListPromptTemplatesQuery,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
const COLLECTION = 'note_prompts';
|
||||||
|
const PARTITION_KEY = '/userId';
|
||||||
|
|
||||||
|
function col() {
|
||||||
|
return getCollection<PromptTemplateDoc>(COLLECTION, PARTITION_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPromptTemplate(
|
||||||
|
userId: string,
|
||||||
|
input: CreatePromptTemplateInput,
|
||||||
|
): Promise<PromptTemplateDoc> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const doc: PromptTemplateDoc = {
|
||||||
|
id: `${input.slug}-${userId}-${Date.now()}`,
|
||||||
|
productId: PRODUCT_ID,
|
||||||
|
userId,
|
||||||
|
slug: input.slug,
|
||||||
|
name: input.name,
|
||||||
|
description: input.description ?? '',
|
||||||
|
systemPrompt: input.systemPrompt,
|
||||||
|
userPromptTemplate: input.userPromptTemplate,
|
||||||
|
inputType: input.inputType ?? 'text',
|
||||||
|
outputType: input.outputType ?? 'new_note',
|
||||||
|
category: input.category ?? 'transform',
|
||||||
|
isBuiltin: false,
|
||||||
|
model: input.model,
|
||||||
|
temperature: input.temperature,
|
||||||
|
maxTokens: input.maxTokens,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
return col().create(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPromptTemplate(
|
||||||
|
id: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<PromptTemplateDoc | null> {
|
||||||
|
return col().findById(id, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPromptTemplateBySlug(
|
||||||
|
slug: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<PromptTemplateDoc | null> {
|
||||||
|
// Find by slug — check user templates first, then builtins
|
||||||
|
const userResults = await col().findMany({
|
||||||
|
filter: { slug, userId, productId: PRODUCT_ID } as FilterMap,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
if (userResults.length > 0) return userResults[0];
|
||||||
|
|
||||||
|
// Check builtins (userId = '__builtin__')
|
||||||
|
const builtinResults = await col().findMany({
|
||||||
|
filter: { slug, userId: '__builtin__', productId: PRODUCT_ID, isBuiltin: true } as FilterMap,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
return builtinResults[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePromptTemplate(
|
||||||
|
id: string,
|
||||||
|
userId: string,
|
||||||
|
input: UpdatePromptTemplateInput,
|
||||||
|
): Promise<PromptTemplateDoc | null> {
|
||||||
|
const existing = await col().findById(id, userId);
|
||||||
|
if (!existing || existing.isBuiltin) return null;
|
||||||
|
|
||||||
|
const updated: PromptTemplateDoc = {
|
||||||
|
...existing,
|
||||||
|
...input,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
return col().upsert(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePromptTemplate(
|
||||||
|
id: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const existing = await col().findById(id, userId);
|
||||||
|
if (!existing || existing.isBuiltin) return false;
|
||||||
|
await col().delete(id, userId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listPromptTemplates(
|
||||||
|
userId: string,
|
||||||
|
query: ListPromptTemplatesQuery,
|
||||||
|
): Promise<{ items: PromptTemplateDoc[]; total: number }> {
|
||||||
|
// Get user templates
|
||||||
|
const userFilter: FilterMap = { userId, productId: PRODUCT_ID };
|
||||||
|
if (query.category) userFilter.category = query.category;
|
||||||
|
|
||||||
|
const userItems = await col().findMany({
|
||||||
|
filter: userFilter,
|
||||||
|
sort: { name: 1 },
|
||||||
|
limit: query.limit + query.offset,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get builtins if requested
|
||||||
|
let builtinItems: PromptTemplateDoc[] = [];
|
||||||
|
if (query.includeBuiltin) {
|
||||||
|
const builtinFilter: FilterMap = {
|
||||||
|
userId: '__builtin__',
|
||||||
|
productId: PRODUCT_ID,
|
||||||
|
isBuiltin: true,
|
||||||
|
};
|
||||||
|
if (query.category) builtinFilter.category = query.category;
|
||||||
|
|
||||||
|
builtinItems = await col().findMany({
|
||||||
|
filter: builtinFilter,
|
||||||
|
sort: { name: 1 },
|
||||||
|
limit: 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge: builtins first, then user
|
||||||
|
const all = [...builtinItems, ...userItems];
|
||||||
|
return {
|
||||||
|
items: all.slice(query.offset, query.offset + query.limit),
|
||||||
|
total: all.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert a built-in prompt template (used by seed).
|
||||||
|
* Uses slug as the id for built-ins so they're idempotent.
|
||||||
|
*/
|
||||||
|
export async function upsertBuiltinTemplate(
|
||||||
|
template: Omit<PromptTemplateDoc, 'createdAt' | 'updatedAt' | '_ts' | '_etag'>,
|
||||||
|
): Promise<PromptTemplateDoc> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const doc: PromptTemplateDoc = {
|
||||||
|
...template,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
return col().upsert(doc);
|
||||||
|
}
|
||||||
124
backend/src/modules/note-prompts/routes.ts
Normal file
124
backend/src/modules/note-prompts/routes.ts
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* Note-prompts routes — CRUD + run prompt templates.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { getUserId, getRequestProductId } from '../../lib/request-context.js';
|
||||||
|
import { BadRequestError, NotFoundError } from '@bytelyst/errors';
|
||||||
|
import {
|
||||||
|
CreatePromptTemplateSchema,
|
||||||
|
UpdatePromptTemplateSchema,
|
||||||
|
ListPromptTemplatesQuerySchema,
|
||||||
|
RunPromptSchema,
|
||||||
|
} from './types.js';
|
||||||
|
import * as repo from './repository.js';
|
||||||
|
import * as noteRepo from '../notes/repository.js';
|
||||||
|
import { executePrompt } from './runner.js';
|
||||||
|
|
||||||
|
export async function notePromptRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
// ── List prompt templates ───────────────────────────────────────
|
||||||
|
app.get('/note-prompts', async (req) => {
|
||||||
|
const userId = getUserId(req);
|
||||||
|
const query = ListPromptTemplatesQuerySchema.parse(req.query);
|
||||||
|
return repo.listPromptTemplates(userId, query);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Get single prompt template ──────────────────────────────────
|
||||||
|
app.get('/note-prompts/:id', async (req) => {
|
||||||
|
const userId = getUserId(req);
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const template = await repo.getPromptTemplate(id, userId);
|
||||||
|
if (!template) {
|
||||||
|
// Try builtin partition
|
||||||
|
const builtin = await repo.getPromptTemplate(id, '__builtin__');
|
||||||
|
if (!builtin) throw new NotFoundError('Prompt template not found');
|
||||||
|
return builtin;
|
||||||
|
}
|
||||||
|
return template;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Create custom prompt template ───────────────────────────────
|
||||||
|
app.post('/note-prompts', async (req, reply) => {
|
||||||
|
const userId = getUserId(req);
|
||||||
|
const input = CreatePromptTemplateSchema.parse(req.body);
|
||||||
|
const created = await repo.createPromptTemplate(userId, input);
|
||||||
|
reply.code(201);
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Update custom prompt template ───────────────────────────────
|
||||||
|
app.patch('/note-prompts/:id', async (req) => {
|
||||||
|
const userId = getUserId(req);
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const input = UpdatePromptTemplateSchema.parse(req.body);
|
||||||
|
const updated = await repo.updatePromptTemplate(id, userId, input);
|
||||||
|
if (!updated) throw new NotFoundError('Prompt template not found or is built-in');
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Delete custom prompt template ───────────────────────────────
|
||||||
|
app.delete('/note-prompts/:id', async (req, reply) => {
|
||||||
|
const userId = getUserId(req);
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const deleted = await repo.deletePromptTemplate(id, userId);
|
||||||
|
if (!deleted) throw new NotFoundError('Prompt template not found or is built-in');
|
||||||
|
reply.code(204);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Run a prompt template against a note ────────────────────────
|
||||||
|
app.post('/note-prompts/run', async (req) => {
|
||||||
|
const userId = getUserId(req);
|
||||||
|
const productId = getRequestProductId(req);
|
||||||
|
const input = RunPromptSchema.parse(req.body);
|
||||||
|
|
||||||
|
// Resolve template — by id or slug
|
||||||
|
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');
|
||||||
|
|
||||||
|
// Validate image requirement
|
||||||
|
if (
|
||||||
|
(template.inputType === 'image' || template.inputType === 'text+image') &&
|
||||||
|
!input.imageUrl
|
||||||
|
) {
|
||||||
|
throw new BadRequestError('This prompt requires an image URL');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get note body
|
||||||
|
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() ?? '';
|
||||||
|
|
||||||
|
const result = await executePrompt(template, input, noteBody);
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Reading time estimate ───────────────────────────────────────
|
||||||
|
app.get('/notes/:id/reading-time', async (req) => {
|
||||||
|
const userId = getUserId(req);
|
||||||
|
const productId = getRequestProductId(req);
|
||||||
|
const { id } = req.params as { id: string };
|
||||||
|
const { workspaceId } = req.query as { workspaceId: string };
|
||||||
|
|
||||||
|
if (!workspaceId) throw new BadRequestError('workspaceId query param required');
|
||||||
|
|
||||||
|
const note = await noteRepo.getNote(id, workspaceId);
|
||||||
|
if (!note || note.userId !== userId || note.productId !== productId) {
|
||||||
|
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));
|
||||||
|
|
||||||
|
return { wordCount, readingTimeMinutes };
|
||||||
|
});
|
||||||
|
}
|
||||||
75
backend/src/modules/note-prompts/runner.ts
Normal file
75
backend/src/modules/note-prompts/runner.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* Prompt runner — executes a PromptTemplate against note content via @bytelyst/llm.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { llm } from '../../lib/llm.js';
|
||||||
|
import { config } from '../../lib/config.js';
|
||||||
|
import {
|
||||||
|
buildVisionMessage,
|
||||||
|
hasVisionContent,
|
||||||
|
type ChatMessage,
|
||||||
|
} from '@bytelyst/llm';
|
||||||
|
import type { PromptTemplateDoc, RunPromptInput, RunPromptOutput } from './types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interpolate {{variable}} placeholders in a template string.
|
||||||
|
*/
|
||||||
|
function interpolate(template: string, vars: Record<string, string>): string {
|
||||||
|
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? `{{${key}}}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a prompt template against provided input.
|
||||||
|
* Handles text-only, image-only, and text+image inputs.
|
||||||
|
*/
|
||||||
|
export async function executePrompt(
|
||||||
|
template: PromptTemplateDoc,
|
||||||
|
input: RunPromptInput,
|
||||||
|
noteBody: string,
|
||||||
|
): Promise<RunPromptOutput> {
|
||||||
|
const provider = llm();
|
||||||
|
|
||||||
|
// Build variables map
|
||||||
|
const vars: Record<string, string> = {
|
||||||
|
...input.variables,
|
||||||
|
noteBody,
|
||||||
|
noteId: input.noteId,
|
||||||
|
workspaceId: input.workspaceId,
|
||||||
|
};
|
||||||
|
if (input.inputText) vars.inputText = input.inputText;
|
||||||
|
|
||||||
|
const userPrompt = interpolate(template.userPromptTemplate, vars);
|
||||||
|
|
||||||
|
// Build messages
|
||||||
|
const messages: ChatMessage[] = [
|
||||||
|
{ role: 'system', content: template.systemPrompt },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (input.imageUrl && (template.inputType === 'image' || template.inputType === 'text+image')) {
|
||||||
|
messages.push(buildVisionMessage(userPrompt, input.imageUrl));
|
||||||
|
} else {
|
||||||
|
messages.push({ role: 'user', content: userPrompt });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select model: vision model for image content, custom model, or default
|
||||||
|
const req = { messages };
|
||||||
|
let model = template.model || config.LLM_DEFAULT_MODEL;
|
||||||
|
if (hasVisionContent(req)) {
|
||||||
|
model = config.LLM_VISION_MODEL;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await provider.chatCompletion({
|
||||||
|
messages,
|
||||||
|
model,
|
||||||
|
temperature: template.temperature ?? 0.7,
|
||||||
|
maxTokens: template.maxTokens ?? 4096,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: result.content,
|
||||||
|
model: result.model,
|
||||||
|
usage: result.usage,
|
||||||
|
templateSlug: template.slug,
|
||||||
|
outputType: template.outputType,
|
||||||
|
};
|
||||||
|
}
|
||||||
246
backend/src/modules/note-prompts/seed.ts
Normal file
246
backend/src/modules/note-prompts/seed.ts
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
/**
|
||||||
|
* Built-in prompt templates — seeded on startup.
|
||||||
|
*
|
||||||
|
* These are available to all users and cannot be edited or deleted.
|
||||||
|
* userId = '__builtin__' is a sentinel for system-owned templates.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PRODUCT_ID } from '../../lib/product-config.js';
|
||||||
|
import type { PromptTemplateDoc } from './types.js';
|
||||||
|
|
||||||
|
const BUILTIN_USER = '__builtin__';
|
||||||
|
|
||||||
|
type SeedTemplate = Omit<PromptTemplateDoc, 'id' | 'productId' | 'userId' | 'isBuiltin' | 'createdAt' | 'updatedAt' | '_ts' | '_etag'>;
|
||||||
|
|
||||||
|
const TEMPLATES: SeedTemplate[] = [
|
||||||
|
// ── Transform ───────────────────────────────────
|
||||||
|
{
|
||||||
|
slug: 'summarize',
|
||||||
|
name: 'Summarize',
|
||||||
|
description: 'Create a concise summary of the note',
|
||||||
|
category: 'transform',
|
||||||
|
inputType: 'text',
|
||||||
|
outputType: 'new_note',
|
||||||
|
systemPrompt: 'You are a concise summarizer. Output a clear, structured summary.',
|
||||||
|
userPromptTemplate: 'Summarize the following note:\n\n{{noteBody}}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'shorten',
|
||||||
|
name: 'Shorten',
|
||||||
|
description: 'Condense the note while keeping key points',
|
||||||
|
category: 'transform',
|
||||||
|
inputType: 'text',
|
||||||
|
outputType: 'replace',
|
||||||
|
systemPrompt: 'You condense text while preserving meaning. Return only the shortened text.',
|
||||||
|
userPromptTemplate: 'Shorten this text to about half its length, keeping key points:\n\n{{noteBody}}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'expand',
|
||||||
|
name: 'Expand',
|
||||||
|
description: 'Expand the note with more detail',
|
||||||
|
category: 'transform',
|
||||||
|
inputType: 'text',
|
||||||
|
outputType: 'replace',
|
||||||
|
systemPrompt: 'You expand text with relevant detail and examples. Return the expanded text.',
|
||||||
|
userPromptTemplate: 'Expand this text with more detail and examples:\n\n{{noteBody}}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'bulletize',
|
||||||
|
name: 'Bullet Points',
|
||||||
|
description: 'Convert the note into bullet points',
|
||||||
|
category: 'transform',
|
||||||
|
inputType: 'text',
|
||||||
|
outputType: 'replace',
|
||||||
|
systemPrompt: 'Convert text into clean bullet points. Return only bullet points.',
|
||||||
|
userPromptTemplate: 'Convert the following text into concise bullet points:\n\n{{noteBody}}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'fix-grammar',
|
||||||
|
name: 'Fix Grammar',
|
||||||
|
description: 'Fix grammar, spelling, and punctuation',
|
||||||
|
category: 'transform',
|
||||||
|
inputType: 'text',
|
||||||
|
outputType: 'replace',
|
||||||
|
systemPrompt: 'Fix grammar, spelling, and punctuation. Preserve original meaning and tone. Return only the corrected text.',
|
||||||
|
userPromptTemplate: '{{noteBody}}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'change-tone-formal',
|
||||||
|
name: 'Make Formal',
|
||||||
|
description: 'Rewrite in a formal, professional tone',
|
||||||
|
category: 'transform',
|
||||||
|
inputType: 'text',
|
||||||
|
outputType: 'replace',
|
||||||
|
systemPrompt: 'Rewrite text in a formal, professional tone. Return only the rewritten text.',
|
||||||
|
userPromptTemplate: 'Rewrite this in a formal, professional tone:\n\n{{noteBody}}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'change-tone-casual',
|
||||||
|
name: 'Make Casual',
|
||||||
|
description: 'Rewrite in a casual, friendly tone',
|
||||||
|
category: 'transform',
|
||||||
|
inputType: 'text',
|
||||||
|
outputType: 'replace',
|
||||||
|
systemPrompt: 'Rewrite text in a casual, friendly tone. Return only the rewritten text.',
|
||||||
|
userPromptTemplate: 'Rewrite this in a casual, friendly tone:\n\n{{noteBody}}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'translate-spanish',
|
||||||
|
name: 'Translate to Spanish',
|
||||||
|
description: 'Translate the note into Spanish',
|
||||||
|
category: 'transform',
|
||||||
|
inputType: 'text',
|
||||||
|
outputType: 'new_note',
|
||||||
|
systemPrompt: 'You are a translator. Translate to Spanish accurately, preserving formatting.',
|
||||||
|
userPromptTemplate: 'Translate to Spanish:\n\n{{noteBody}}',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Extract ─────────────────────────────────────
|
||||||
|
{
|
||||||
|
slug: 'extract-action-items',
|
||||||
|
name: 'Extract Action Items',
|
||||||
|
description: 'Extract tasks and action items from the note',
|
||||||
|
category: 'extract',
|
||||||
|
inputType: 'text',
|
||||||
|
outputType: 'artifact',
|
||||||
|
systemPrompt: 'Extract action items and tasks. Return as a numbered list. Each item should be actionable.',
|
||||||
|
userPromptTemplate: 'Extract all action items and tasks from this note:\n\n{{noteBody}}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'extract-key-facts',
|
||||||
|
name: 'Extract Key Facts',
|
||||||
|
description: 'Extract key facts and data points',
|
||||||
|
category: 'extract',
|
||||||
|
inputType: 'text',
|
||||||
|
outputType: 'artifact',
|
||||||
|
systemPrompt: 'Extract key facts, statistics, dates, and important data points. Return as a structured list.',
|
||||||
|
userPromptTemplate: 'Extract key facts and data points from:\n\n{{noteBody}}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'extract-questions',
|
||||||
|
name: 'Extract Open Questions',
|
||||||
|
description: 'Identify unanswered questions in the note',
|
||||||
|
category: 'extract',
|
||||||
|
inputType: 'text',
|
||||||
|
outputType: 'artifact',
|
||||||
|
systemPrompt: 'Identify questions that are raised but not answered. Return as a numbered list.',
|
||||||
|
userPromptTemplate: 'Identify unanswered questions in this note:\n\n{{noteBody}}',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Generate ────────────────────────────────────
|
||||||
|
{
|
||||||
|
slug: 'continue-writing',
|
||||||
|
name: 'Continue Writing',
|
||||||
|
description: 'Continue writing from where the note ends',
|
||||||
|
category: 'generate',
|
||||||
|
inputType: 'text',
|
||||||
|
outputType: 'inline',
|
||||||
|
systemPrompt: 'Continue writing in the same style and tone. Output only the continuation, not the original text.',
|
||||||
|
userPromptTemplate: 'Continue writing from where this text ends:\n\n{{noteBody}}',
|
||||||
|
temperature: 0.8,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'suggest-title',
|
||||||
|
name: 'Suggest Title',
|
||||||
|
description: 'Suggest a title for the note',
|
||||||
|
category: 'generate',
|
||||||
|
inputType: 'text',
|
||||||
|
outputType: 'inline',
|
||||||
|
systemPrompt: 'Suggest a concise, descriptive title (max 8 words). Return only the title, no quotes.',
|
||||||
|
userPromptTemplate: 'Suggest a title for this note:\n\n{{noteBody}}',
|
||||||
|
temperature: 0.6,
|
||||||
|
maxTokens: 64,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'generate-outline',
|
||||||
|
name: 'Generate Outline',
|
||||||
|
description: 'Create a structured outline from the note',
|
||||||
|
category: 'generate',
|
||||||
|
inputType: 'text',
|
||||||
|
outputType: 'new_note',
|
||||||
|
systemPrompt: 'Create a structured outline with headers and sub-points. Use markdown formatting.',
|
||||||
|
userPromptTemplate: 'Create a structured outline from this content:\n\n{{noteBody}}',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Analyze ─────────────────────────────────────
|
||||||
|
{
|
||||||
|
slug: 'analyze-sentiment',
|
||||||
|
name: 'Analyze Sentiment',
|
||||||
|
description: 'Analyze the sentiment and tone of the note',
|
||||||
|
category: 'analyze',
|
||||||
|
inputType: 'text',
|
||||||
|
outputType: 'artifact',
|
||||||
|
systemPrompt: 'Analyze sentiment and tone. Report: overall sentiment (positive/negative/neutral), confidence, key emotional indicators, tone description.',
|
||||||
|
userPromptTemplate: 'Analyze the sentiment and tone of this text:\n\n{{noteBody}}',
|
||||||
|
maxTokens: 512,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'reading-level',
|
||||||
|
name: 'Reading Level',
|
||||||
|
description: 'Assess the reading difficulty level',
|
||||||
|
category: 'analyze',
|
||||||
|
inputType: 'text',
|
||||||
|
outputType: 'artifact',
|
||||||
|
systemPrompt: 'Assess reading level. Report: grade level, Flesch-Kincaid estimate, vocabulary complexity, sentence complexity, suggestions to simplify.',
|
||||||
|
userPromptTemplate: 'Assess the reading level of this text:\n\n{{noteBody}}',
|
||||||
|
maxTokens: 512,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Vision ──────────────────────────────────────
|
||||||
|
{
|
||||||
|
slug: 'describe-image',
|
||||||
|
name: 'Describe Image',
|
||||||
|
description: 'Generate a text description of an image',
|
||||||
|
category: 'extract',
|
||||||
|
inputType: 'image',
|
||||||
|
outputType: 'new_note',
|
||||||
|
systemPrompt: 'Describe the image in detail. Include key visual elements, text visible in the image, and overall composition.',
|
||||||
|
userPromptTemplate: 'Describe this image in detail.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'extract-text-from-image',
|
||||||
|
name: 'Extract Text from Image',
|
||||||
|
description: 'OCR — extract visible text from an image',
|
||||||
|
category: 'extract',
|
||||||
|
inputType: 'image',
|
||||||
|
outputType: 'new_note',
|
||||||
|
systemPrompt: 'Extract all visible text from the image. Preserve formatting where possible. Return only the extracted text.',
|
||||||
|
userPromptTemplate: 'Extract all text visible in this image.',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Export ──────────────────────────────────────
|
||||||
|
{
|
||||||
|
slug: 'to-email-draft',
|
||||||
|
name: 'Draft Email',
|
||||||
|
description: 'Convert the note into an email draft',
|
||||||
|
category: 'export',
|
||||||
|
inputType: 'text',
|
||||||
|
outputType: 'new_note',
|
||||||
|
systemPrompt: 'Convert the note into a professional email draft with subject line, greeting, body, and sign-off.',
|
||||||
|
userPromptTemplate: 'Convert this note into an email draft:\n\n{{noteBody}}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'to-social-post',
|
||||||
|
name: 'Draft Social Post',
|
||||||
|
description: 'Convert the note into a social media post',
|
||||||
|
category: 'export',
|
||||||
|
inputType: 'text',
|
||||||
|
outputType: 'new_note',
|
||||||
|
systemPrompt: 'Convert the note into an engaging social media post. Keep it concise and impactful. Include relevant hashtags.',
|
||||||
|
userPromptTemplate: 'Convert this note into a social media post:\n\n{{noteBody}}',
|
||||||
|
maxTokens: 512,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build full PromptTemplateDoc objects from seed data.
|
||||||
|
*/
|
||||||
|
export function getBuiltinTemplates(): Omit<PromptTemplateDoc, 'createdAt' | 'updatedAt' | '_ts' | '_etag'>[] {
|
||||||
|
return TEMPLATES.map((t) => ({
|
||||||
|
...t,
|
||||||
|
id: `builtin-${t.slug}`,
|
||||||
|
productId: PRODUCT_ID,
|
||||||
|
userId: BUILTIN_USER,
|
||||||
|
isBuiltin: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
99
backend/src/modules/note-prompts/types.ts
Normal file
99
backend/src/modules/note-prompts/types.ts
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// ── Prompt Template ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const PROMPT_CATEGORIES = ['transform', 'extract', 'generate', 'analyze', 'export'] as const;
|
||||||
|
export type PromptCategory = (typeof PROMPT_CATEGORIES)[number];
|
||||||
|
|
||||||
|
export const PROMPT_INPUT_TYPES = ['text', 'image', 'text+image'] as const;
|
||||||
|
export type PromptInputType = (typeof PROMPT_INPUT_TYPES)[number];
|
||||||
|
|
||||||
|
export const PROMPT_OUTPUT_TYPES = ['replace', 'new_note', 'artifact', 'inline'] as const;
|
||||||
|
export type PromptOutputType = (typeof PROMPT_OUTPUT_TYPES)[number];
|
||||||
|
|
||||||
|
export interface PromptTemplateDoc {
|
||||||
|
id: string;
|
||||||
|
productId: string;
|
||||||
|
userId: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
systemPrompt: string;
|
||||||
|
userPromptTemplate: string;
|
||||||
|
inputType: PromptInputType;
|
||||||
|
outputType: PromptOutputType;
|
||||||
|
category: PromptCategory;
|
||||||
|
isBuiltin: boolean;
|
||||||
|
model?: string;
|
||||||
|
temperature?: number;
|
||||||
|
maxTokens?: number;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
_ts?: number;
|
||||||
|
_etag?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Run Prompt ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const RunPromptSchema = z.object({
|
||||||
|
templateId: z.string().min(1).max(128),
|
||||||
|
noteId: z.string().min(1).max(128),
|
||||||
|
workspaceId: z.string().min(1).max(128),
|
||||||
|
inputText: z.string().max(100_000).optional(),
|
||||||
|
imageUrl: z.string().url().max(4096).optional(),
|
||||||
|
variables: z.record(z.string()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RunPromptInput = z.infer<typeof RunPromptSchema>;
|
||||||
|
|
||||||
|
export interface RunPromptOutput {
|
||||||
|
content: string;
|
||||||
|
model: string;
|
||||||
|
usage: { promptTokens: number; completionTokens: number; totalTokens: number };
|
||||||
|
templateSlug: string;
|
||||||
|
outputType: PromptOutputType;
|
||||||
|
createdNoteId?: string;
|
||||||
|
createdArtifactId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CRUD Schemas ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const CreatePromptTemplateSchema = z.object({
|
||||||
|
slug: z.string().min(1).max(64).regex(/^[a-z0-9-]+$/),
|
||||||
|
name: z.string().min(1).max(128),
|
||||||
|
description: z.string().max(1000).default(''),
|
||||||
|
systemPrompt: z.string().max(8000),
|
||||||
|
userPromptTemplate: z.string().max(8000),
|
||||||
|
inputType: z.enum(PROMPT_INPUT_TYPES).default('text'),
|
||||||
|
outputType: z.enum(PROMPT_OUTPUT_TYPES).default('new_note'),
|
||||||
|
category: z.enum(PROMPT_CATEGORIES).default('transform'),
|
||||||
|
model: z.string().max(128).optional(),
|
||||||
|
temperature: z.number().min(0).max(2).optional(),
|
||||||
|
maxTokens: z.number().int().min(1).max(128_000).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CreatePromptTemplateInput = z.infer<typeof CreatePromptTemplateSchema>;
|
||||||
|
|
||||||
|
export const UpdatePromptTemplateSchema = z.object({
|
||||||
|
name: z.string().min(1).max(128).optional(),
|
||||||
|
description: z.string().max(1000).optional(),
|
||||||
|
systemPrompt: z.string().max(8000).optional(),
|
||||||
|
userPromptTemplate: z.string().max(8000).optional(),
|
||||||
|
inputType: z.enum(PROMPT_INPUT_TYPES).optional(),
|
||||||
|
outputType: z.enum(PROMPT_OUTPUT_TYPES).optional(),
|
||||||
|
category: z.enum(PROMPT_CATEGORIES).optional(),
|
||||||
|
model: z.string().max(128).optional(),
|
||||||
|
temperature: z.number().min(0).max(2).optional(),
|
||||||
|
maxTokens: z.number().int().min(1).max(128_000).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type UpdatePromptTemplateInput = z.infer<typeof UpdatePromptTemplateSchema>;
|
||||||
|
|
||||||
|
export const ListPromptTemplatesQuerySchema = z.object({
|
||||||
|
category: z.enum(PROMPT_CATEGORIES).optional(),
|
||||||
|
includeBuiltin: z.coerce.boolean().default(true),
|
||||||
|
limit: z.coerce.number().int().min(1).max(100).default(50),
|
||||||
|
offset: z.coerce.number().int().min(0).default(0),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ListPromptTemplatesQuery = z.infer<typeof ListPromptTemplatesQuerySchema>;
|
||||||
@ -9,6 +9,7 @@ import { noteRelationshipRoutes } from './modules/note-relationships/routes.js';
|
|||||||
import { noteTaskRoutes } from './modules/note-tasks/routes.js';
|
import { noteTaskRoutes } from './modules/note-tasks/routes.js';
|
||||||
import { savedViewRoutes } from './modules/saved-views/routes.js';
|
import { savedViewRoutes } from './modules/saved-views/routes.js';
|
||||||
import { workspaceRoutes } from './modules/workspaces/routes.js';
|
import { workspaceRoutes } from './modules/workspaces/routes.js';
|
||||||
|
import { notePromptRoutes } from './modules/note-prompts/routes.js';
|
||||||
import { initCosmosIfNeeded } from './lib/cosmos-init.js';
|
import { initCosmosIfNeeded } from './lib/cosmos-init.js';
|
||||||
import { initEncryption } from './lib/field-encrypt.js';
|
import { initEncryption } from './lib/field-encrypt.js';
|
||||||
import { initDatastore } from './lib/datastore.js';
|
import { initDatastore } from './lib/datastore.js';
|
||||||
@ -61,6 +62,7 @@ await registerApiPlugin(noteRelationshipRoutes);
|
|||||||
await registerApiPlugin(noteTaskRoutes);
|
await registerApiPlugin(noteTaskRoutes);
|
||||||
await registerApiPlugin(savedViewRoutes);
|
await registerApiPlugin(savedViewRoutes);
|
||||||
await registerApiPlugin(workspaceRoutes);
|
await registerApiPlugin(workspaceRoutes);
|
||||||
|
await registerApiPlugin(notePromptRoutes);
|
||||||
|
|
||||||
// ── Public read-only share (no auth) ───────────────────────────────
|
// ── Public read-only share (no auth) ───────────────────────────────
|
||||||
app.get('/api/public/note-shares/:token', async (req, reply) => {
|
app.get('/api/public/note-shares/:token', async (req, reply) => {
|
||||||
|
|||||||
25
pnpm-lock.yaml
generated
25
pnpm-lock.yaml
generated
@ -42,8 +42,8 @@ importers:
|
|||||||
specifier: ^0.1.0
|
specifier: ^0.1.0
|
||||||
version: 0.1.0
|
version: 0.1.0
|
||||||
'@bytelyst/events':
|
'@bytelyst/events':
|
||||||
specifier: file:/tmp/bytelyst-events-0.1.0.tgz
|
specifier: ^0.1.0
|
||||||
version: file:../../../../../tmp/bytelyst-events-0.1.0.tgz(zod@3.25.76)
|
version: 0.1.0(zod@3.25.76)
|
||||||
'@bytelyst/fastify-auth':
|
'@bytelyst/fastify-auth':
|
||||||
specifier: ^0.1.0
|
specifier: ^0.1.0
|
||||||
version: 0.1.0(fastify@5.7.4)(jose@6.2.2)
|
version: 0.1.0(fastify@5.7.4)(jose@6.2.2)
|
||||||
@ -53,6 +53,9 @@ importers:
|
|||||||
'@bytelyst/field-encrypt':
|
'@bytelyst/field-encrypt':
|
||||||
specifier: ^0.1.0
|
specifier: ^0.1.0
|
||||||
version: 0.1.0(@azure/keyvault-keys@4.10.0(@azure/core-client@1.10.1))(zod@3.25.76)
|
version: 0.1.0(@azure/keyvault-keys@4.10.0(@azure/core-client@1.10.1))(zod@3.25.76)
|
||||||
|
'@bytelyst/llm':
|
||||||
|
specifier: file:../../learning_ai_common_plat/packages/llm
|
||||||
|
version: file:../learning_ai_common_plat/packages/llm
|
||||||
'@bytelyst/logger':
|
'@bytelyst/logger':
|
||||||
specifier: ^0.1.0
|
specifier: ^0.1.0
|
||||||
version: 0.1.0
|
version: 0.1.0
|
||||||
@ -972,9 +975,8 @@ packages:
|
|||||||
'@bytelyst/errors@0.1.0':
|
'@bytelyst/errors@0.1.0':
|
||||||
resolution: {integrity: sha512-hE4sHwmQUDGZYDdo3w7VuRdVfuaXgEcG2f0KD0ZLJF+EgfRmDV3IevD1ubPsJIIZxMu8brK8zZOvPohhsMsYdw==, tarball: http://localhost:3300/api/packages/bytelyst/npm/%40bytelyst%2Ferrors/-/0.1.0/errors-0.1.0.tgz}
|
resolution: {integrity: sha512-hE4sHwmQUDGZYDdo3w7VuRdVfuaXgEcG2f0KD0ZLJF+EgfRmDV3IevD1ubPsJIIZxMu8brK8zZOvPohhsMsYdw==, tarball: http://localhost:3300/api/packages/bytelyst/npm/%40bytelyst%2Ferrors/-/0.1.0/errors-0.1.0.tgz}
|
||||||
|
|
||||||
'@bytelyst/events@file:../../../../../tmp/bytelyst-events-0.1.0.tgz':
|
'@bytelyst/events@0.1.0':
|
||||||
resolution: {integrity: sha512-9HmjfrDMmR63UHVbuaruIPEbALWwApcdH/lfeOiF6W+pFpd6FV3yrnLh04gKMzxBOWyGzysH+vrGI7Xnt4PpnQ==, tarball: file:../../../../../tmp/bytelyst-events-0.1.0.tgz}
|
resolution: {integrity: sha512-iiBXWPCoSlzYnvOBdhkLnpOtyp+oz0uIPmeB5UvMBqn6oiZ35NbwqUQnh8xfmsEbOUT6ESPipuwG8k/R5r4Kkw==, tarball: http://localhost:3300/api/packages/bytelyst/npm/%40bytelyst%2Fevents/-/0.1.0/events-0.1.0.tgz}
|
||||||
version: 0.1.0
|
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
zod: ^3.0.0
|
zod: ^3.0.0
|
||||||
|
|
||||||
@ -1025,6 +1027,9 @@ packages:
|
|||||||
'@bytelyst/kill-switch-client@0.1.0':
|
'@bytelyst/kill-switch-client@0.1.0':
|
||||||
resolution: {integrity: sha512-XVXltkFVFrE7pbR9J4tVGQA1o+0Jr3YPOz1KiUu7oU9Pkwfty0pmVElIgoChAaNStmhAfhkdMw9ftJDU4WqzJQ==, tarball: http://localhost:3300/api/packages/bytelyst/npm/%40bytelyst%2Fkill-switch-client/-/0.1.0/kill-switch-client-0.1.0.tgz}
|
resolution: {integrity: sha512-XVXltkFVFrE7pbR9J4tVGQA1o+0Jr3YPOz1KiUu7oU9Pkwfty0pmVElIgoChAaNStmhAfhkdMw9ftJDU4WqzJQ==, tarball: http://localhost:3300/api/packages/bytelyst/npm/%40bytelyst%2Fkill-switch-client/-/0.1.0/kill-switch-client-0.1.0.tgz}
|
||||||
|
|
||||||
|
'@bytelyst/llm@file:../learning_ai_common_plat/packages/llm':
|
||||||
|
resolution: {directory: ../learning_ai_common_plat/packages/llm, type: directory}
|
||||||
|
|
||||||
'@bytelyst/logger@0.1.0':
|
'@bytelyst/logger@0.1.0':
|
||||||
resolution: {integrity: sha512-Ow7svVI+w5nxaRfS1f0tTRypu+auAznSAtpgTUeLOa60Px162xYk6UGT4DhGTf2ReLZcLrcU5n0qa/FolidzZw==, tarball: http://localhost:3300/api/packages/bytelyst/npm/%40bytelyst%2Flogger/-/0.1.0/logger-0.1.0.tgz}
|
resolution: {integrity: sha512-Ow7svVI+w5nxaRfS1f0tTRypu+auAznSAtpgTUeLOa60Px162xYk6UGT4DhGTf2ReLZcLrcU5n0qa/FolidzZw==, tarball: http://localhost:3300/api/packages/bytelyst/npm/%40bytelyst%2Flogger/-/0.1.0/logger-0.1.0.tgz}
|
||||||
|
|
||||||
@ -7171,7 +7176,7 @@ snapshots:
|
|||||||
|
|
||||||
'@bytelyst/errors@0.1.0': {}
|
'@bytelyst/errors@0.1.0': {}
|
||||||
|
|
||||||
'@bytelyst/events@file:../../../../../tmp/bytelyst-events-0.1.0.tgz(zod@3.25.76)':
|
'@bytelyst/events@0.1.0(zod@3.25.76)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@bytelyst/queue': 0.1.0
|
'@bytelyst/queue': 0.1.0
|
||||||
zod: 3.25.76
|
zod: 3.25.76
|
||||||
@ -7208,6 +7213,8 @@ snapshots:
|
|||||||
|
|
||||||
'@bytelyst/kill-switch-client@0.1.0': {}
|
'@bytelyst/kill-switch-client@0.1.0': {}
|
||||||
|
|
||||||
|
'@bytelyst/llm@file:../learning_ai_common_plat/packages/llm': {}
|
||||||
|
|
||||||
'@bytelyst/logger@0.1.0': {}
|
'@bytelyst/logger@0.1.0': {}
|
||||||
|
|
||||||
'@bytelyst/offline-queue@0.1.0': {}
|
'@bytelyst/offline-queue@0.1.0': {}
|
||||||
@ -10119,7 +10126,7 @@ snapshots:
|
|||||||
eslint: 9.39.4(jiti@2.6.1)
|
eslint: 9.39.4(jiti@2.6.1)
|
||||||
eslint-import-resolver-node: 0.3.9
|
eslint-import-resolver-node: 0.3.9
|
||||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))
|
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))
|
||||||
eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
|
eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))
|
||||||
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1))
|
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1))
|
||||||
eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1))
|
eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1))
|
||||||
eslint-plugin-react-hooks: 7.0.1(eslint@9.39.4(jiti@2.6.1))
|
eslint-plugin-react-hooks: 7.0.1(eslint@9.39.4(jiti@2.6.1))
|
||||||
@ -10152,7 +10159,7 @@ snapshots:
|
|||||||
tinyglobby: 0.2.15
|
tinyglobby: 0.2.15
|
||||||
unrs-resolver: 1.11.1
|
unrs-resolver: 1.11.1
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1))
|
eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1))
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@ -10230,7 +10237,7 @@ snapshots:
|
|||||||
- eslint-import-resolver-webpack
|
- eslint-import-resolver-webpack
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
eslint-plugin-import@2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)):
|
eslint-plugin-import@2.32.0(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@rtsao/scc': 1.1.0
|
'@rtsao/scc': 1.1.0
|
||||||
array-includes: 3.1.9
|
array-includes: 3.1.9
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user