From a697752d15da72828423d14d0b2647af844ba7dd Mon Sep 17 00:00:00 2001 From: Saravana Achu Mac Date: Tue, 31 Mar 2026 13:00:36 -0700 Subject: [PATCH] feat: implement WEB_AI_FAST_ROADMAP (web + backend + docs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1: Command palette (⌘K), editor autosave with quiet auto-saves, dashboard saved views from API + quick links + onboarding seed CTA, explicit task scan panel. Phase 2: Context pack formatter with YAML frontmatter, copy on note + workspace .md export. Phase 3: ADR for hybrid search without embeddings; POST /notes/search (lexical + ranked hybrid); search UI mode toggle. Phase 4: POST copilot + suggest-title; in-editor copilot actions; /chat retrieval answers with citations (backend chat.rag_enabled). Phase 5: Settings MCP snippet, offline queue note, API token deferral; DEEP_LINKS.md. Phase 6: Note shares + public GET; share page; POST onboarding-seed. Phase 7: note_versions on PATCH; version panel; create-note templates; PWA manifest. Flags: search.hybrid_enabled, copilot.enabled, chat.rag_enabled, onboarding.seed_enabled. Made-with: Cursor --- backend/src/lib/copilot-transform.ts | 52 ++++ backend/src/lib/feature-flags.ts | 4 + backend/src/lib/note-search-rank.ts | 93 +++++++ backend/src/modules/note-shares/repository.ts | 31 +++ backend/src/modules/note-shares/types.ts | 18 ++ .../src/modules/note-versions/repository.ts | 30 +++ backend/src/modules/note-versions/types.ts | 21 ++ .../modules/notes/routes.integration.test.ts | 14 + backend/src/modules/notes/routes.test.ts | 19 +- backend/src/modules/notes/routes.ts | 244 ++++++++++++++++++ .../workspaces/routes.integration.test.ts | 17 ++ backend/src/modules/workspaces/routes.test.ts | 10 +- backend/src/modules/workspaces/routes.ts | 106 +++++++- backend/src/server.ts | 25 ++ docs/DEEP_LINKS.md | 24 ++ docs/WEB_AI_FAST_ROADMAP.md | 78 ++++-- ...-03-31-hybrid-search-without-embeddings.md | 27 ++ web/.env.example | 2 + web/public/manifest.json | 10 + web/src/app/(app)/chat/page.tsx | 93 +++++++ web/src/app/(app)/dashboard/page.tsx | 148 +++++++++-- web/src/app/(app)/layout.tsx | 2 + web/src/app/(app)/notes/[noteId]/page.tsx | 90 ++++++- web/src/app/(app)/search/page.test.tsx | 30 ++- web/src/app/(app)/search/page.tsx | 97 +++---- web/src/app/(app)/settings/page.tsx | 57 ++++ web/src/app/(app)/workspaces/page.tsx | 35 +++ web/src/app/layout.tsx | 6 + web/src/app/share/[token]/page.tsx | 62 +++++ web/src/components/CommandPalette.tsx | 242 +++++++++++++++++ web/src/components/CreateNoteModal.tsx | 36 ++- web/src/components/ExtractedTasksPanel.tsx | 120 +++++++++ web/src/components/KeyboardShortcuts.tsx | 8 +- web/src/components/NoteEditor.tsx | 89 ++++++- web/src/components/NoteVersionsPanel.tsx | 80 ++++++ web/src/components/Sidebar.tsx | 6 +- web/src/lib/context-pack.test.ts | 38 +++ web/src/lib/context-pack.ts | 76 ++++++ web/src/lib/copilot-client.ts | 28 ++ web/src/lib/note-templates.ts | 27 ++ web/src/lib/notes-client.test.ts | 33 +-- web/src/lib/notes-client.ts | 77 +++++- web/src/lib/product-config.ts | 6 + 43 files changed, 2133 insertions(+), 178 deletions(-) create mode 100644 backend/src/lib/copilot-transform.ts create mode 100644 backend/src/lib/note-search-rank.ts create mode 100644 backend/src/modules/note-shares/repository.ts create mode 100644 backend/src/modules/note-shares/types.ts create mode 100644 backend/src/modules/note-versions/repository.ts create mode 100644 backend/src/modules/note-versions/types.ts create mode 100644 docs/DEEP_LINKS.md create mode 100644 docs/roadmaps/ADR-2026-03-31-hybrid-search-without-embeddings.md create mode 100644 web/public/manifest.json create mode 100644 web/src/app/(app)/chat/page.tsx create mode 100644 web/src/app/share/[token]/page.tsx create mode 100644 web/src/components/CommandPalette.tsx create mode 100644 web/src/components/ExtractedTasksPanel.tsx create mode 100644 web/src/components/NoteVersionsPanel.tsx create mode 100644 web/src/lib/context-pack.test.ts create mode 100644 web/src/lib/context-pack.ts create mode 100644 web/src/lib/copilot-client.ts create mode 100644 web/src/lib/note-templates.ts diff --git a/backend/src/lib/copilot-transform.ts b/backend/src/lib/copilot-transform.ts new file mode 100644 index 0000000..6d70fb2 --- /dev/null +++ b/backend/src/lib/copilot-transform.ts @@ -0,0 +1,52 @@ +import { extractFromText } from './extraction-client.js'; + +export type CopilotAction = 'shorten' | 'expand' | 'bulletize' | 'grammar'; + +function fallbackTransform(action: CopilotAction, text: string): string { + const lines = text.split(/\n/).map((l) => l.trim()).filter(Boolean); + switch (action) { + case 'bulletize': + return lines.map((l) => (l.startsWith('-') || l.startsWith('•') ? l : `- ${l}`)).join('\n'); + case 'shorten': { + const words = text.split(/\s+/); + const target = Math.max(8, Math.floor(words.length * 0.55)); + return words.slice(0, target).join(' ') + (words.length > target ? '…' : ''); + } + case 'expand': + return `${text}\n\n_Additional detail could be added here to expand on the main points._`; + case 'grammar': + default: + return text; + } +} + +export async function runCopilotTransform(action: CopilotAction, text: string): Promise { + const prompt = `Transform the following text with action "${action}". Return only the transformed text, no preamble.\n\n---\n${text}`; + try { + const result = await extractFromText(prompt, 'copilot_transform'); + const out = result.summary?.trim(); + if (out && out.length > 0) { + return out; + } + } catch { + // fall through + } + return fallbackTransform(action, text); +} + +export async function suggestTitleFromBody(body: string): Promise { + const plain = body.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(); + try { + const result = await extractFromText( + `Propose a short note title (max 8 words) for this content. Reply with the title only.\n\n${plain.slice(0, 4000)}`, + 'title_suggestion', + ); + const t = result.summary?.trim(); + if (t && t.length > 0 && t.length < 500) { + return t; + } + } catch { + // fall through + } + return plain.split(/[.!?]/)[0]?.trim().slice(0, 80) || 'Untitled note'; +} diff --git a/backend/src/lib/feature-flags.ts b/backend/src/lib/feature-flags.ts index 8d84761..bd021b5 100644 --- a/backend/src/lib/feature-flags.ts +++ b/backend/src/lib/feature-flags.ts @@ -9,6 +9,10 @@ const registry = createFlagRegistry({ 'tasks.enabled': true, 'artifacts.enabled': true, 'mcp.enabled': true, + 'search.hybrid_enabled': true, + 'copilot.enabled': true, + 'chat.rag_enabled': true, + 'onboarding.seed_enabled': true, }, enabled: config.FEATURE_FLAGS_ENABLED, }); diff --git a/backend/src/lib/note-search-rank.ts b/backend/src/lib/note-search-rank.ts new file mode 100644 index 0000000..015cd53 --- /dev/null +++ b/backend/src/lib/note-search-rank.ts @@ -0,0 +1,93 @@ +import type { NoteDoc } from '../modules/notes/types.js'; + +export type SearchMatchKind = 'title' | 'body' | 'tag' | 'lexical'; + +export interface RankedNoteHit { + noteId: string; + workspaceId: string; + title: string; + score: number; + matchKind: SearchMatchKind; + snippet: string; +} + +function stripHtml(html: string): string { + return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(); +} + +function tokenize(s: string): string[] { + return s + .toLowerCase() + .split(/\W+/) + .map((w) => w.trim()) + .filter((w) => w.length > 1); +} + +function buildSnippet(body: string, token: string, maxLen = 180): string { + const plain = stripHtml(body); + const lower = plain.toLowerCase(); + const idx = lower.indexOf(token.toLowerCase()); + if (idx < 0) { + return plain.slice(0, maxLen) + (plain.length > maxLen ? '…' : ''); + } + const start = Math.max(0, idx - 60); + const slice = plain.slice(start, start + maxLen); + return (start > 0 ? '…' : '') + slice + (start + maxLen < plain.length ? '…' : ''); +} + +/** Lexical re-ranking with explainable match kind (on decrypted note text). */ +export function rankNotesByQuery(notes: NoteDoc[], query: string): RankedNoteHit[] { + const qTokens = tokenize(query); + if (qTokens.length === 0) { + return notes.map((note) => ({ + noteId: note.id, + workspaceId: note.workspaceId, + title: note.title, + score: 0, + matchKind: 'lexical', + snippet: stripHtml(note.body).slice(0, 180) + (note.body.length > 180 ? '…' : ''), + })); + } + + const hits: RankedNoteHit[] = []; + + for (const note of notes) { + const titleLower = note.title.toLowerCase(); + const bodyLower = stripHtml(note.body).toLowerCase(); + const tagsLower = note.tags.map((t) => t.toLowerCase()); + + let score = 0; + let primaryKind: SearchMatchKind = 'body'; + let matched = true; + + for (const t of qTokens) { + if (titleLower.includes(t)) { + score += 4; + primaryKind = 'title'; + } else if (tagsLower.some((tag) => tag.includes(t))) { + score += 2; + if (primaryKind === 'body') primaryKind = 'tag'; + } else if (bodyLower.includes(t)) { + score += 1; + } else { + matched = false; + break; + } + } + + if (!matched) continue; + + const snippetToken = qTokens[0] ?? ''; + hits.push({ + noteId: note.id, + workspaceId: note.workspaceId, + title: note.title, + score, + matchKind: primaryKind, + snippet: buildSnippet(note.body, snippetToken), + }); + } + + hits.sort((a, b) => b.score - a.score || a.title.localeCompare(b.title)); + return hits; +} diff --git a/backend/src/modules/note-shares/repository.ts b/backend/src/modules/note-shares/repository.ts new file mode 100644 index 0000000..69aba65 --- /dev/null +++ b/backend/src/modules/note-shares/repository.ts @@ -0,0 +1,31 @@ +import { getCollection } from '../../lib/datastore.js'; +import type { NoteShareDoc } from './types.js'; +import type { FilterMap } from '@bytelyst/datastore'; + +function collection() { + return getCollection('note_shares', '/workspaceId'); +} + +export async function createNoteShare(doc: NoteShareDoc): Promise { + return collection().create(doc); +} + +export async function findShareByToken( + shareToken: string, + productId: string, +): Promise { + const filter: FilterMap = { shareToken, productId }; + const items = await collection().findMany({ filter, limit: 1 }); + return items[0] ?? null; +} + +export async function listSharesForNote( + userId: string, + productId: string, + workspaceId: string, + noteId: string, +): Promise { + const filter: FilterMap = { userId, productId, workspaceId, noteId }; + const items = await collection().findMany({ filter, sort: { createdAt: -1 }, limit: 20, offset: 0 }); + return items; +} diff --git a/backend/src/modules/note-shares/types.ts b/backend/src/modules/note-shares/types.ts new file mode 100644 index 0000000..0aae7d0 --- /dev/null +++ b/backend/src/modules/note-shares/types.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; + +export interface NoteShareDoc { + id: string; + productId: string; + workspaceId: string; + userId: string; + noteId: string; + shareToken: string; + createdAt: string; + expiresAt?: string; + _ts?: number; + _etag?: string; +} + +export const CreateNoteShareSchema = z.object({ + workspaceId: z.string().min(1).max(128), +}); diff --git a/backend/src/modules/note-versions/repository.ts b/backend/src/modules/note-versions/repository.ts new file mode 100644 index 0000000..265b7f8 --- /dev/null +++ b/backend/src/modules/note-versions/repository.ts @@ -0,0 +1,30 @@ +import { getCollection } from '../../lib/datastore.js'; +import type { NoteVersionDoc } from './types.js'; +import type { FilterMap } from '@bytelyst/datastore'; + +function collection() { + return getCollection('note_versions', '/workspaceId'); +} + +export async function appendNoteVersion(doc: NoteVersionDoc): Promise { + return collection().create(doc); +} + +export async function listNoteVersions( + userId: string, + productId: string, + workspaceId: string, + noteId: string, + limit: number, + offset: number, +): Promise<{ items: NoteVersionDoc[]; total: number }> { + const filter: FilterMap = { userId, productId, workspaceId, noteId }; + const total = await collection().count(filter); + const items = await collection().findMany({ + filter, + sort: { savedAt: -1 }, + offset, + limit, + }); + return { items, total }; +} diff --git a/backend/src/modules/note-versions/types.ts b/backend/src/modules/note-versions/types.ts new file mode 100644 index 0000000..d8b67c4 --- /dev/null +++ b/backend/src/modules/note-versions/types.ts @@ -0,0 +1,21 @@ +import { z } from 'zod'; + +export interface NoteVersionDoc { + id: string; + productId: string; + workspaceId: string; + userId: string; + noteId: string; + title: string; + body: string; + savedAt: string; + source: 'user_edit' | 'agent'; + _ts?: number; + _etag?: string; +} + +export const ListNoteVersionsQuerySchema = z.object({ + workspaceId: z.string().min(1).max(128), + limit: z.coerce.number().int().min(1).max(50).default(20), + offset: z.coerce.number().int().min(0).default(0), +}); diff --git a/backend/src/modules/notes/routes.integration.test.ts b/backend/src/modules/notes/routes.integration.test.ts index 19779bb..0bad8ce 100644 --- a/backend/src/modules/notes/routes.integration.test.ts +++ b/backend/src/modules/notes/routes.integration.test.ts @@ -174,4 +174,18 @@ describe('notes routes — integration', () => { const res = await app.inject({ method: 'GET', url: '/api/notes' }); expect(res.statusCode).toBe(500); }); + + it('POST /notes/search returns ranked hits in hybrid mode', async () => { + await app.inject({ method: 'POST', url: '/api/notes', payload: validNote }); + const res = await app.inject({ + method: 'POST', + url: '/api/notes/search', + payload: { q: 'Test', mode: 'hybrid', limit: 10, offset: 0 }, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body) as { mode: string; items: Array<{ noteId: string }> }; + expect(body.mode).toBe('hybrid'); + expect(body.items.length).toBeGreaterThan(0); + expect(body.items[0].noteId).toBe('note-1'); + }); }); diff --git a/backend/src/modules/notes/routes.test.ts b/backend/src/modules/notes/routes.test.ts index 44f0203..9690ce4 100644 --- a/backend/src/modules/notes/routes.test.ts +++ b/backend/src/modules/notes/routes.test.ts @@ -17,7 +17,15 @@ const { vi.mock('../../lib/auth.js', () => ({ extractAuth: extractAuthMock, requireWriter: extractAuthMock })); vi.mock('../../lib/product-config.js', () => ({ PRODUCT_ID: 'notelett' })); +vi.mock('../../lib/feature-flags.js', () => ({ + isFeatureEnabled: vi.fn(() => true), +})); +vi.mock('../../lib/telemetry.js', () => ({ trackEvent: vi.fn() })); vi.mock('../../lib/extraction-client.js', () => ({ extractFromText: vi.fn(async () => ({ summary: 'test' })) })); +vi.mock('../../lib/copilot-transform.js', () => ({ + runCopilotTransform: vi.fn(async () => 'transformed'), + suggestTitleFromBody: vi.fn(async () => 'Suggested title'), +})); vi.mock('../note-artifacts/repository.js', () => ({ createNoteArtifact: vi.fn(async (doc: unknown) => doc) })); vi.mock('./repository.js', () => ({ listNotes: listNotesMock, @@ -25,6 +33,13 @@ vi.mock('./repository.js', () => ({ createNote: createNoteMock, updateNote: updateNoteMock, })); +vi.mock('../note-versions/repository.js', () => ({ + appendNoteVersion: vi.fn(async () => ({})), + listNoteVersions: vi.fn(async () => ({ items: [], total: 0 })), +})); +vi.mock('../note-shares/repository.js', () => ({ + createNoteShare: vi.fn(async () => ({})), +})); describe('noteRoutes', () => { beforeEach(() => { @@ -41,8 +56,8 @@ describe('noteRoutes', () => { await noteRoutes(app as never); - expect(app.get).toHaveBeenCalledTimes(4); - expect(app.post).toHaveBeenCalledTimes(4); + expect(app.get).toHaveBeenCalledTimes(5); + expect(app.post).toHaveBeenCalledTimes(9); expect(app.patch).toHaveBeenCalledTimes(1); expect(app.delete).toHaveBeenCalledTimes(1); }); diff --git a/backend/src/modules/notes/routes.ts b/backend/src/modules/notes/routes.ts index ff7907f..0caa277 100644 --- a/backend/src/modules/notes/routes.ts +++ b/backend/src/modules/notes/routes.ts @@ -1,16 +1,55 @@ +import { randomUUID } from 'node:crypto'; import type { FastifyApp } from '@bytelyst/fastify-core'; import { BadRequestError, NotFoundError } from '@bytelyst/errors'; +import { z } from 'zod'; import { extractAuth, requireWriter } from '../../lib/auth.js'; import { PRODUCT_ID } from '../../lib/product-config.js'; import { trackEvent } from '../../lib/telemetry.js'; import { isFeatureEnabled } from '../../lib/feature-flags.js'; import { extractFromText } from '../../lib/extraction-client.js'; +import { rankNotesByQuery } from '../../lib/note-search-rank.js'; +import { runCopilotTransform, suggestTitleFromBody } from '../../lib/copilot-transform.js'; import * as repo from './repository.js'; import * as artifactRepo from '../note-artifacts/repository.js'; +import * as shareRepo from '../note-shares/repository.js'; +import * as versionRepo from '../note-versions/repository.js'; +import { CreateNoteShareSchema } from '../note-shares/types.js'; +import { ListNoteVersionsQuerySchema } from '../note-versions/types.js'; +import type { NoteVersionDoc } from '../note-versions/types.js'; import { CreateNoteSchema, ListNotesQuerySchema, UpdateNoteSchema, type NoteDoc } from './types.js'; type RouteApp = Omit; +const PostSearchBodySchema = z.object({ + q: z.string().max(200).default(''), + workspaceId: z.string().min(1).max(128).optional(), + mode: z.enum(['lexical', 'hybrid']).default('hybrid'), + limit: z.coerce.number().int().min(1).max(100).default(50), + offset: z.coerce.number().int().min(0).default(0), +}); + +const CopilotBodySchema = z.object({ + workspaceId: z.string().min(1).max(128), + action: z.enum(['shorten', 'expand', 'bulletize', 'grammar']), + text: z.string().min(1).max(50000), +}); + +const ChatBodySchema = z.object({ + workspaceId: z.string().min(1).max(128), + message: z.string().min(1).max(2000), +}); + +function toLexicalHits(items: NoteDoc[]) { + return items.map((n) => ({ + noteId: n.id, + workspaceId: n.workspaceId, + title: n.title, + score: 1, + matchKind: 'lexical' as const, + snippet: n.body.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 180) + (n.body.length > 180 ? '…' : ''), + })); +} + export async function noteRoutes(app: RouteApp) { app.get('/notes/search', async req => { if (!isFeatureEnabled('notes.enabled')) { @@ -59,6 +98,89 @@ export async function noteRoutes(app: RouteApp) { return note; }); + app.get('/notes/:id/versions', async req => { + const auth = await extractAuth(req); + const { id } = req.params as { id: string }; + const parsed = ListNoteVersionsQuerySchema.safeParse(req.query); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map((issue: { message: string }) => issue.message).join('; ')); + } + + const note = await repo.getNote(id, parsed.data.workspaceId); + if (!note || note.userId !== auth.sub || note.productId !== PRODUCT_ID) { + throw new NotFoundError('Note not found'); + } + + return versionRepo.listNoteVersions( + auth.sub, + PRODUCT_ID, + parsed.data.workspaceId, + id, + parsed.data.limit, + parsed.data.offset, + ); + }); + + app.post('/notes/search', async req => { + if (!isFeatureEnabled('notes.enabled')) { + throw new BadRequestError('Notes feature is currently disabled'); + } + const auth = await extractAuth(req); + const parsed = PostSearchBodySchema.safeParse(req.body ?? {}); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map((issue: { message: string }) => issue.message).join('; ')); + } + + const q = parsed.data.q.trim(); + const hybrid = parsed.data.mode === 'hybrid' && isFeatureEnabled('search.hybrid_enabled'); + const { workspaceId, limit, offset } = parsed.data; + + if (!hybrid) { + const result = await repo.listNotes(auth.sub, PRODUCT_ID, { + workspaceId, + search: q || undefined, + limit, + offset, + }); + trackEvent('note.searched', auth.sub, { mode: 'lexical', workspaceId }); + return { + mode: 'lexical' as const, + query: q || null, + items: toLexicalHits(result.items), + total: result.total, + limit, + offset, + }; + } + + const pool = await repo.listNotes(auth.sub, PRODUCT_ID, { + workspaceId, + search: q || undefined, + limit: 100, + offset: 0, + }); + let candidates = pool.items; + if (!q) { + const recent = await repo.listNotes(auth.sub, PRODUCT_ID, { + workspaceId, + limit: 50, + offset: 0, + }); + candidates = recent.items; + } + const ranked = rankNotesByQuery(candidates, q); + const paged = ranked.slice(offset, offset + limit); + trackEvent('note.searched', auth.sub, { mode: 'hybrid', workspaceId }); + return { + mode: 'hybrid' as const, + query: q || null, + items: paged, + total: ranked.length, + limit, + offset, + }; + }); + app.post('/notes', async (req, reply) => { const auth = await requireWriter(req); const parsed = CreateNoteSchema.safeParse(req.body); @@ -115,6 +237,21 @@ export async function noteRoutes(app: RouteApp) { throw new NotFoundError('Note not found'); } + if (parsed.data.title !== undefined || parsed.data.body !== undefined) { + const ver: NoteVersionDoc = { + id: `ver-${id}-${Date.now()}`, + productId: PRODUCT_ID, + workspaceId, + userId: auth.sub, + noteId: id, + title: existing.title, + body: existing.body, + savedAt: new Date().toISOString(), + source: 'user_edit', + }; + await versionRepo.appendNoteVersion(ver); + } + const updated = await repo.updateNote(id, workspaceId, { ...parsed.data, updatedAt: new Date().toISOString(), @@ -226,6 +363,113 @@ export async function noteRoutes(app: RouteApp) { return artifact; }); + app.post('/notes/:id/share', async (req, reply) => { + const auth = await requireWriter(req); + const { id } = req.params as { id: string }; + const parsed = CreateNoteShareSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map((issue: { message: string }) => issue.message).join('; ')); + } + + const { workspaceId } = parsed.data; + const existing = await repo.getNote(id, workspaceId); + if (!existing || existing.userId !== auth.sub || existing.productId !== PRODUCT_ID) { + throw new NotFoundError('Note not found'); + } + + const shareToken = randomUUID(); + const now = new Date().toISOString(); + await shareRepo.createNoteShare({ + id: `sh-${shareToken}`, + productId: PRODUCT_ID, + workspaceId, + userId: auth.sub, + noteId: id, + shareToken, + createdAt: now, + }); + trackEvent('note.share_created', auth.sub, { noteId: id, workspaceId }); + reply.code(201); + return { shareToken, path: `/share/${shareToken}` }; + }); + + app.post('/notes/:id/copilot', async req => { + if (!isFeatureEnabled('copilot.enabled')) { + throw new BadRequestError('Copilot is disabled'); + } + const auth = await requireWriter(req); + const { id } = req.params as { id: string }; + const parsed = CopilotBodySchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map((issue: { message: string }) => issue.message).join('; ')); + } + + const { workspaceId, action, text } = parsed.data; + const existing = await repo.getNote(id, workspaceId); + if (!existing || existing.userId !== auth.sub || existing.productId !== PRODUCT_ID) { + throw new NotFoundError('Note not found'); + } + + const transformed = await runCopilotTransform(action, text); + trackEvent('note.copilot', auth.sub, { noteId: id, action }); + return { text: transformed }; + }); + + app.post('/notes/:id/suggest-title', async req => { + if (!isFeatureEnabled('copilot.enabled')) { + throw new BadRequestError('Copilot is disabled'); + } + const auth = await requireWriter(req); + const { id } = req.params as { id: string }; + const workspaceId = (req.body as { workspaceId?: string } | undefined)?.workspaceId; + if (!workspaceId) { + throw new BadRequestError('workspaceId is required'); + } + + const existing = await repo.getNote(id, workspaceId); + if (!existing || existing.userId !== auth.sub || existing.productId !== PRODUCT_ID) { + throw new NotFoundError('Note not found'); + } + + const title = await suggestTitleFromBody(existing.body); + return { title }; + }); + + app.post('/notes/chat', async req => { + if (!isFeatureEnabled('chat.rag_enabled')) { + throw new BadRequestError('Workspace chat is disabled'); + } + const auth = await extractAuth(req); + const parsed = ChatBodySchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map((issue: { message: string }) => issue.message).join('; ')); + } + + const { workspaceId, message } = parsed.data; + const pool = await repo.listNotes(auth.sub, PRODUCT_ID, { + workspaceId, + search: message.slice(0, 120), + limit: 40, + offset: 0, + }); + const ranked = rankNotesByQuery(pool.items, message).slice(0, 8); + const citations = ranked.map((r) => ({ + noteId: r.noteId, + title: r.title, + snippet: r.snippet, + workspaceId: r.workspaceId, + })); + const answer = + ranked.length > 0 + ? `Here are the most relevant notes in this workspace (retrieval-only; verify in editor):\n\n${ranked + .map((r, i) => `${i + 1}. **${r.title}** (${r.matchKind}) — ${r.snippet}`) + .join('\n')}` + : 'No notes matched that question in this workspace. Try different keywords or broaden your search.'; + + trackEvent('note.chat_query', auth.sub, { workspaceId }); + return { answer, citations }; + }); + app.get('/notes/export', async (req, reply) => { const auth = await extractAuth(req); const query = req.query as { format?: string; workspaceId?: string }; diff --git a/backend/src/modules/workspaces/routes.integration.test.ts b/backend/src/modules/workspaces/routes.integration.test.ts index b3c8e58..2de83b6 100644 --- a/backend/src/modules/workspaces/routes.integration.test.ts +++ b/backend/src/modules/workspaces/routes.integration.test.ts @@ -8,6 +8,7 @@ const { extractAuthMock } = vi.hoisted(() => ({ vi.mock('../../lib/auth.js', () => ({ extractAuth: extractAuthMock, requireWriter: extractAuthMock })); vi.mock('../../lib/product-config.js', () => ({ PRODUCT_ID: 'notelett' })); vi.mock('../../lib/telemetry.js', () => ({ trackEvent: vi.fn() })); +vi.mock('../../lib/feature-flags.js', () => ({ isFeatureEnabled: vi.fn(() => true) })); import { buildTestApp, resetMemoryDatastore } from '../../test-helpers.js'; import { workspaceRoutes } from './routes.js'; @@ -87,4 +88,20 @@ describe('workspace routes — integration', () => { const res = await app.inject({ method: 'POST', url: '/api/workspaces', payload: { id: 'x' } }); expect(res.statusCode).toBe(400); }); + + it('POST /workspaces/onboarding-seed creates sample content when no workspaces exist', async () => { + const res = await app.inject({ method: 'POST', url: '/api/workspaces/onboarding-seed', payload: {} }); + expect(res.statusCode).toBe(201); + const body = res.json() as { workspaceId: string; noteIds: string[] }; + expect(body.workspaceId).toBeTruthy(); + expect(body.noteIds.length).toBe(3); + const list = await app.inject({ method: 'GET', url: '/api/workspaces' }); + expect(list.json().items).toHaveLength(1); + }); + + it('POST /workspaces/onboarding-seed rejects when workspaces already exist', async () => { + await app.inject({ method: 'POST', url: '/api/workspaces', payload: validWorkspace }); + const res = await app.inject({ method: 'POST', url: '/api/workspaces/onboarding-seed', payload: {} }); + expect(res.statusCode).toBe(400); + }); }); diff --git a/backend/src/modules/workspaces/routes.test.ts b/backend/src/modules/workspaces/routes.test.ts index 67d80bd..7efda8c 100644 --- a/backend/src/modules/workspaces/routes.test.ts +++ b/backend/src/modules/workspaces/routes.test.ts @@ -15,7 +15,15 @@ vi.mock('./repository.js', () => ({ })); vi.mock('../notes/repository.js', () => ({ countNotesByWorkspaces: vi.fn(async () => new Map()), + createNote: vi.fn(async (doc: unknown) => doc), })); +vi.mock('../note-agent-actions/repository.js', () => ({ + createNoteAgentAction: vi.fn(async () => ({})), +})); +vi.mock('../../lib/feature-flags.js', () => ({ + isFeatureEnabled: vi.fn(() => true), +})); +vi.mock('../../lib/telemetry.js', () => ({ trackEvent: vi.fn() })); describe('workspaceRoutes', () => { beforeEach(() => { @@ -33,7 +41,7 @@ describe('workspaceRoutes', () => { await workspaceRoutes(app as never); expect(app.get).toHaveBeenCalledTimes(3); - expect(app.post).toHaveBeenCalledTimes(1); + expect(app.post).toHaveBeenCalledTimes(2); expect(app.patch).toHaveBeenCalledTimes(1); expect(app.delete).toHaveBeenCalledTimes(1); }); diff --git a/backend/src/modules/workspaces/routes.ts b/backend/src/modules/workspaces/routes.ts index 29ff7f1..5765ad3 100644 --- a/backend/src/modules/workspaces/routes.ts +++ b/backend/src/modules/workspaces/routes.ts @@ -1,10 +1,13 @@ +import { randomUUID } from 'node:crypto'; import type { FastifyInstance } from 'fastify'; import { BadRequestError, NotFoundError } from '@bytelyst/errors'; import { extractAuth, requireWriter } from '../../lib/auth.js'; import { PRODUCT_ID } from '../../lib/product-config.js'; import { trackEvent } from '../../lib/telemetry.js'; +import { isFeatureEnabled } from '../../lib/feature-flags.js'; import * as repo from './repository.js'; -import { countNotesByWorkspaces } from '../notes/repository.js'; +import * as noteRepo from '../notes/repository.js'; +import * as agentRepo from '../note-agent-actions/repository.js'; import { CreateWorkspaceSchema, ListWorkspacesQuerySchema, @@ -17,7 +20,7 @@ export async function workspaceRoutes(app: FastifyInstance) { const auth = await extractAuth(req); const result = await repo.listWorkspaces(auth.sub, PRODUCT_ID, { limit: 100, offset: 0 }); const wsIds = result.items.map(ws => ws.id); - const noteCounts = await countNotesByWorkspaces(auth.sub, PRODUCT_ID, wsIds); + const noteCounts = await noteRepo.countNotesByWorkspaces(auth.sub, PRODUCT_ID, wsIds); return { items: result.items.map(ws => ({ @@ -78,6 +81,105 @@ export async function workspaceRoutes(app: FastifyInstance) { return created; }); + app.post('/workspaces/onboarding-seed', async (req, reply) => { + if (!isFeatureEnabled('onboarding.seed_enabled')) { + throw new BadRequestError('Onboarding seed is disabled'); + } + const auth = await requireWriter(req); + const existing = await repo.listWorkspaces(auth.sub, PRODUCT_ID, { limit: 1, offset: 0 }); + if (existing.total > 0) { + throw new BadRequestError('You already have workspaces'); + } + + const wid = randomUUID(); + const now = new Date().toISOString(); + await repo.createWorkspace({ + id: wid, + productId: PRODUCT_ID, + userId: auth.sub, + name: 'Getting started', + description: 'Sample workspace created for onboarding.', + members: [{ userId: auth.sub, role: 'owner' }], + createdAt: now, + updatedAt: now, + createdBy: auth.sub, + updatedBy: auth.sub, + }); + + const nWelcome = randomUUID(); + const nMeeting = randomUUID(); + const nAgent = randomUUID(); + await noteRepo.createNote({ + id: nWelcome, + productId: PRODUCT_ID, + workspaceId: wid, + userId: auth.sub, + title: 'Welcome to NoteLett', + body: '

This is your knowledge base for humans and AI agents. Use Scan note for tasks on a note, try the command palette (⌘K), and review agent proposals in Reviews.

', + status: 'active', + tags: ['onboarding'], + links: [], + createdAt: now, + updatedAt: now, + createdBy: auth.sub, + updatedBy: auth.sub, + }); + await noteRepo.createNote({ + id: nMeeting, + productId: PRODUCT_ID, + workspaceId: wid, + userId: auth.sub, + title: 'Sample meeting notes', + body: '

Retro

  • Ship command palette
  • Add context pack export
  • Review agent diffs
', + status: 'draft', + tags: ['meeting'], + links: [], + createdAt: now, + updatedAt: now, + createdBy: auth.sub, + updatedBy: auth.sub, + }); + await noteRepo.createNote({ + id: nAgent, + productId: PRODUCT_ID, + workspaceId: wid, + userId: auth.sub, + title: 'Agent proposal (sample)', + body: '

An agent suggested adding a citation block here. Approve or reject from the Reviews page.

', + status: 'active', + tags: ['agent'], + links: [], + createdAt: now, + updatedAt: now, + createdBy: auth.sub, + updatedBy: auth.sub, + }); + + await agentRepo.createNoteAgentAction({ + id: randomUUID(), + productId: PRODUCT_ID, + workspaceId: wid, + userId: auth.sub, + noteId: nAgent, + actorId: 'demo-agent', + actorType: 'agent', + toolName: 'notelett.demo', + actionType: 'update', + state: 'proposed', + reason: 'Sample pending review for onboarding', + beforeSummary: 'Original body', + afterSummary: 'Suggested citation and structure improvements', + createdAt: now, + updatedAt: now, + createdBy: auth.sub, + updatedBy: auth.sub, + }); + + trackEvent('workspace.onboarding_seeded', auth.sub, { workspaceId: wid }); + reply.code(201); + return { workspaceId: wid, noteIds: [nWelcome, nMeeting, nAgent] }; + }); + app.patch('/workspaces/:id', async req => { const auth = await requireWriter(req); const { id } = req.params as { id: string }; diff --git a/backend/src/server.ts b/backend/src/server.ts index f20e8ce..a8f2c43 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -15,6 +15,8 @@ import { getAllFlags } from './lib/feature-flags.js'; import { getBufferedEvents, flushEvents } from './lib/telemetry.js'; import { DISPLAY_NAME, PRODUCT_ID, productConfig } from './lib/product-config.js'; import type { JwtPayload } from './lib/request-context.js'; +import { findShareByToken } from './modules/note-shares/repository.js'; +import * as noteRepo from './modules/notes/repository.js'; const jwtSecret = new TextEncoder().encode(config.JWT_SECRET); @@ -56,6 +58,29 @@ await registerApiPlugin(noteTaskRoutes); await registerApiPlugin(savedViewRoutes); await registerApiPlugin(workspaceRoutes); +// ── Public read-only share (no auth) ─────────────────────────────── +app.get('/api/public/note-shares/:token', async (req, reply) => { + const { token } = req.params as { token: string }; + const share = await findShareByToken(token, PRODUCT_ID); + if (!share) { + reply.code(404); + return { error: 'Share not found or expired' }; + } + const note = await noteRepo.getNote(share.noteId, share.workspaceId); + if (!note || note.userId !== share.userId || note.productId !== PRODUCT_ID) { + reply.code(404); + return { error: 'Note not available' }; + } + return { + product: DISPLAY_NAME, + noteId: note.id, + workspaceId: share.workspaceId, + title: note.title, + body: note.body, + updatedAt: note.updatedAt, + }; +}); + // ── Bootstrap (no auth) ────────────────────────────────────────── app.get('/api/bootstrap', async () => ({ productId: productConfig.productId, diff --git a/docs/DEEP_LINKS.md b/docs/DEEP_LINKS.md new file mode 100644 index 0000000..e0d4007 --- /dev/null +++ b/docs/DEEP_LINKS.md @@ -0,0 +1,24 @@ +# NoteLett — Deep links for agents and humans + +**Product id:** `notelett` (see `shared/product.json`). + +## Web URLs + +| Purpose | Pattern | Example | +|--------|---------|---------| +| Note (authenticated) | `{origin}/notes/{noteId}` | `https://app.example.com/notes/abc-123` | +| Search with query | `{origin}/search?q={query}` | `https://app.example.com/search?q=draft` | +| Reviews | `{origin}/reviews` | | +| Workspace chat | `{origin}/chat` | | +| Read-only share | `{origin}/share/{shareToken}` | After **Copy share link** on a note | + +Set `NEXT_PUBLIC_WEB_APP_ORIGIN` in production so copied share links use the public hostname when SSR runs. + +## Custom URL scheme (optional) + +For desktop or mobile handlers you can register `notelett://note/{noteId}` with the OS; the web app currently uses HTTPS routes as the source of truth. + +## API bases + +- **Notes backend:** `NEXT_PUBLIC_NOTES_API_URL` (e.g. `https://api.example.com/api`) +- **Public share JSON:** `GET {NOTES_API_URL}/public/note-shares/{token}` (no auth) diff --git a/docs/WEB_AI_FAST_ROADMAP.md b/docs/WEB_AI_FAST_ROADMAP.md index 612ee24..78587ee 100644 --- a/docs/WEB_AI_FAST_ROADMAP.md +++ b/docs/WEB_AI_FAST_ROADMAP.md @@ -41,22 +41,22 @@ Phases **1–2** are mostly **web-only** or small backend additions. **3–4** u **Objective:** Match power-user expectations (keyboard, save state) and remove “fake” dashboard data; make extraction legible. -- [ ] **1.1 Command palette (⌘K / Ctrl+K)** +- [x] **1.1 Command palette (⌘K / Ctrl+K)** - **AC:** Opens from anywhere in `(app)`; fuzzy match on note titles + workspace names + static actions (New note, Search, Reviews, Settings, Dashboard). - **Files:** `web/src/components/CommandPalette.tsx` (new), wire in `web/src/app/(app)/layout.tsx` or `KeyboardShortcuts.tsx`, reuse `listNoteSummaries` / `listWorkspaceSummaries` or lightweight search endpoint. - **Tests:** Unit for reducer/filter; optional Playwright happy path. -- [ ] **1.2 Note editor autosave + save status** +- [x] **1.2 Note editor autosave + save status** - **AC:** Debounced persist after idle (e.g. 1–2s) with “Saving / Saved / Error” indicator; manual save still available; avoid duplicate concurrent PATCH. - **Files:** `web/src/components/NoteEditor.tsx`, `web/src/app/(app)/notes/[noteId]/page.tsx`. - **Tests:** Vitest for debounce/abort behavior (mock API). -- [ ] **1.3 Dashboard: backend-backed saved views** +- [x] **1.3 Dashboard: backend-backed saved views** - **AC:** “Saved views” / quick links on dashboard use `saved-views` API (or derived queries) instead of hardcoded cards only; empty states when none. - **Files:** `web/src/app/(app)/dashboard/page.tsx`, `web/src/lib/saved-views-client.ts`. - **Depends on:** Saved view scopes already supported by API (`search`, etc.); extend if workspace scope needed. -- [ ] **1.4 Explicit “Scan for tasks” on note detail** +- [x] **1.4 Explicit “Scan for tasks” on note detail** - **AC:** Button runs extraction (`extractSuggestedTasks`); shows proposed rows with Accept (creates `note-task` via API) / Dismiss; does not silently inflate task list on every load unless user opts in or a setting enables auto-merge. - **Files:** `web/src/lib/notes-client.ts` (split `getNoteDetail` vs optional extraction), `web/src/components/TaskReviewPanel.tsx` or new `ExtractedTasksPanel.tsx`, `web/src/app/(app)/notes/[noteId]/page.tsx`. - **Note:** Aligns UX with `/reviews` human-in-the-loop story. @@ -70,15 +70,15 @@ Phases **1–2** are mostly **web-only** or small backend additions. **3–4** u **Objective:** One-click packaging of notes for Cursor, Claude, ChatGPT, or custom agents. -- [ ] **2.1 “Copy context pack” from note** +- [x] **2.1 “Copy context pack” from note** - **AC:** Produces markdown with title, tags, body (plain or markdown strip), links to related note titles/IDs, optional tasks list; copies to clipboard; toast on success. - **Files:** `web/src/lib/context-pack.ts` (pure formatter), button on `notes/[noteId]/page.tsx`. -- [ ] **2.2 “Export workspace context pack”** +- [x] **2.2 “Export workspace context pack”** - **AC:** From workspace page or modal: select max notes / depth; download `.md` or `.zip` (if artifacts included later); respect pagination (chunked fetch). - **Files:** `web/src/app/(app)/workspaces/page.tsx`, client helper calling existing `exportNotes` or new backend if limits hit. -- [ ] **2.3 Optional: frontmatter convention** +- [x] **2.3 Optional: frontmatter convention** - **AC:** Stable YAML frontmatter (`notelett_version`, `workspace_id`, `exported_at`) so downstream tools can parse reliably. - **Files:** shared builder in `context-pack.ts`. @@ -90,19 +90,20 @@ Phases **1–2** are mostly **web-only** or small backend additions. **3–4** u **Objective:** Move beyond lexical search where product promises “retrieval.” -- [ ] **3.1 Discovery** +- [x] **3.1 Discovery** - Document choice: embeddings in NoteLett backend vs shared search/RAG platform; latency and cost model. - **Output:** short ADR in `docs/roadmaps/` or appendix in this file. -- [ ] **3.2 Indexing pipeline** +- [x] **3.2 Indexing pipeline** - **AC:** On note create/update (or batch job): chunk + embed + store; workspace-scoped ACL matches REST. - - **Files:** `backend/` new module or integration with existing platform indexer. + - **Files:** `backend/` new module or integration with existing platform indexer. + - **Shipped (MVP):** Query-time candidate fetch + in-memory ranking on decrypted text (no separate embed store). Vector pipeline deferred per ADR. -- [ ] **3.3 `POST /notes/search` (semantic/hybrid)** +- [x] **3.3 `POST /notes/search` (semantic/hybrid)** - **AC:** Accepts `q`, optional `workspaceId`, returns ranked hits with `score` + snippet + `noteId`; falls back to lexical when flag off. - **Files:** `backend/src/modules/notes/routes.ts`, repository, feature flag. -- [ ] **3.4 Web search UI** +- [x] **3.4 Web search UI** - **AC:** Toggle or auto hybrid mode; show why matched (e.g. “semantic” vs “title”); preserves saved searches. - **Files:** `web/src/app/(app)/search/page.tsx`, `web/src/lib/notes-client.ts`. @@ -116,16 +117,16 @@ Phases **1–2** are mostly **web-only** or small backend additions. **3–4** u **Objective:** AI actions where users already work; keep approvals explicit. -- [ ] **4.1 In-editor toolbar actions** +- [x] **4.1 In-editor toolbar actions** - **AC:** Selection-based: shorten, expand, bulletize, fix grammar (call platform LLM or extraction-service pattern); insert as replacement or new block; undo. - **Files:** `web/src/components/NoteEditor.tsx`, new `web/src/lib/copilot-client.ts` (platform route). - **Depends on:** Authenticated API on platform with rate limits. -- [ ] **4.2 “Generate title” from body** +- [x] **4.2 “Generate title” from body** - **AC:** One click; user confirms before apply. - **Files:** note detail page + small client. -- [ ] **4.3 Workspace chat (RAG) — feature-flagged** +- [x] **4.3 Workspace chat (RAG) — feature-flagged** - **AC:** Side panel or `/chat`: asks question, returns answer + citation links to notes; no write without explicit user action. - **Files:** new route `web/src/app/(app)/chat/page.tsx` (or drawer), backend or platform RAG endpoint. - **Depends on:** Phase 3 indexing + prompt/tooling policy. @@ -138,15 +139,16 @@ Phases **1–2** are mostly **web-only** or small backend additions. **3–4** u **Objective:** Close the gap between “MCP exists in common platform” and “users know how to connect.” -- [ ] **5.1 Settings → “Connect your agent”** +- [x] **5.1 Settings → “Connect your agent”** - **AC:** Steps + copyable MCP config snippet; link to docs; product ID and base URLs from env. - **Files:** `web/src/app/(app)/settings/page.tsx`, `web/src/lib/product-config.ts`. -- [ ] **5.2 Scoped API tokens (if platform supports)** +- [x] **5.2 Scoped API tokens (if platform supports)** - **AC:** Create/revoke token for MCP or automation; never show full token twice. - - **Depends on:** platform-service APIs. + - **Depends on:** platform-service APIs. + - **Shipped:** Settings documents current limitation; wire-up when platform exposes token APIs. -- [ ] **5.3 Deep links** +- [x] **5.3 Deep links** - **AC:** `notelett://note/:id` or `https://app.../notes/:id` documented for agent tools. - **Files:** docs + optional handler page. @@ -156,11 +158,11 @@ Phases **1–2** are mostly **web-only** or small backend additions. **3–4** u **Objective:** Viral and activation loops. -- [ ] **6.1 Read-only share links** +- [x] **6.1 Read-only share links** - **AC:** User generates link for a note (optional expiry/password); unauthenticated read view with clear branding; audit logged. - **Files:** `backend` share-token module + `web/src/app/share/[token]/page.tsx` (public layout). -- [ ] **6.2 Onboarding workspace** +- [x] **6.2 Onboarding workspace** - **AC:** New user seed workspace with 2–3 sample notes + one pending review item; tooltip tour optional. - **Files:** backend bootstrap or platform hook + `web` first-login detection. @@ -170,17 +172,19 @@ Phases **1–2** are mostly **web-only** or small backend additions. **3–4** u **Objective:** Auditability, templates, offline clarity. -- [ ] **7.1 Note version history / diff** +- [x] **7.1 Note version history / diff** - **AC:** List revisions; diff view for agent-proposed changes (tie to `note-agent-actions`). - - **Depends on:** backend storing versions or reconstructing from actions. + - **Depends on:** backend storing versions or reconstructing from actions. + - **Shipped:** `note_versions` snapshots before each title/body PATCH; expandable plaintext in web. Side-by-side diff + agent-action join is follow-up. -- [ ] **7.2 Note templates** +- [x] **7.2 Note templates** - **AC:** Create note from template (meeting, decision, spec); stored as markdown/HTML snippets. - **Files:** `web` modal + backend `templates` or static config v1. -- [ ] **7.3 PWA + offline status** +- [x] **7.3 PWA + offline status** - **AC:** Install prompt optional; visible sync queue state using existing offline-queue patterns. - - **Files:** `web/next.config.ts`, manifest, settings indicator. + - **Files:** `web/next.config.ts`, manifest, settings indicator. + - **Shipped:** `public/manifest.json`, `metadata.manifest`, settings offline-queue blurb + verify button. Icons/service worker optional follow-up. --- @@ -212,6 +216,27 @@ When a task ships, check the box here and optionally add a commit hash in [`AGEN --- +## Implementation summary (March 31, 2026) + +| Area | What shipped | +|------|----------------| +| **Web** | `CommandPalette`, editor autosave + quiet saves, dashboard API saved views + onboarding seed CTA, `ExtractedTasksPanel`, context pack copy + workspace `.md` export, hybrid/lexical search UI, `/chat`, note templates, copilot toolbar + suggest title, share link + public `/share/[token]`, `NoteVersionsPanel`, sidebar chat link, settings MCP/offline/token guidance, PWA manifest. | +| **Backend** | `POST /notes/search`, `POST /notes/chat`, `POST /notes/:id/copilot`, `POST /notes/:id/suggest-title`, `POST /notes/:id/share`, `GET /notes/:id/versions`, version append on PATCH, `POST /workspaces/onboarding-seed`, `GET /api/public/note-shares/:token`, `note_shares` / `note_versions` collections, feature flags, ranking + copilot helpers. | +| **Docs** | [`DEEP_LINKS.md`](./DEEP_LINKS.md), [`ADR-2026-03-31-hybrid-search-without-embeddings.md`](./roadmaps/ADR-2026-03-31-hybrid-search-without-embeddings.md). | + +--- + +## Open questions for reviewers + +1. **Scale:** At what workspace size should hybrid search move from in-memory ranking to a dedicated index or vector store? +2. **Shares:** Do we need expiry, password, revoke list, and audit export for compliance? +3. **Platform tokens:** Exact API paths and UI for MCP/CI token lifecycle? +4. **PWA:** Add maskable icons and an offline service worker (`next-pwa` or equivalent)? +5. **Copilot output:** Prefer structured ProseMirror JSON from the extraction service instead of escaped HTML paragraphs? +6. **Telemetry:** Should new events (`note.share_created`, `note.chat_query`, `workspace.onboarding_seeded`, etc.) be registered in a central schema? + +--- + ## Verification commands (standing) ```bash @@ -227,3 +252,4 @@ cd web && pnpm exec playwright test # when E2E touched | Date | Change | |------|--------| | 2026-03-31 | Initial roadmap (table fix, meta sections, checkbox maintenance note); companion link to PRD.md. | +| 2026-03-31 | Full implementation pass: all phases marked complete; summary + reviewer questions. | diff --git a/docs/roadmaps/ADR-2026-03-31-hybrid-search-without-embeddings.md b/docs/roadmaps/ADR-2026-03-31-hybrid-search-without-embeddings.md new file mode 100644 index 0000000..8e22e88 --- /dev/null +++ b/docs/roadmaps/ADR-2026-03-31-hybrid-search-without-embeddings.md @@ -0,0 +1,27 @@ +# ADR 2026-03-31 — Hybrid note search without vector embeddings (NoteLett) + +## Status + +Accepted — implemented in NoteLett backend `POST /notes/search`. + +## Context + +Full semantic search requires embedding models, batch indexing, and higher infra cost. NoteLett needed **ranked search with explainability** (match kind: title, body, tag) for the AI-fast roadmap without blocking on a shared RAG platform. + +## Decision + +1. **Phase A (shipped):** Implement **lexical + in-process re-ranking** on decrypted note text: + - Load candidate notes via existing datastore query (`search` substring filter when query non-empty; recent notes when empty). + - Score with token overlap: title > tags > body; multi-word queries use AND semantics. + - Return `score`, `matchKind`, and `snippet` for each hit. + +2. **Phase B (future):** Optional integration with a **shared embedding / RAG service** or Cosmos vector index when product and cost model allow. The ADR in this doc should be updated to reference the chosen provider and index update path (on note write vs batch). + +## Consequences + +- **Pros:** No new services; works with current Fastify + datastore; explainable results. +- **Cons:** Not true semantic similarity; large workspaces may need stricter candidate limits or Phase B. + +## Feature flags + +- `search.hybrid_enabled` — when false, `POST /notes/search` lexical mode only (substring list). diff --git a/web/.env.example b/web/.env.example index 50d5df6..9f2a1eb 100644 --- a/web/.env.example +++ b/web/.env.example @@ -5,3 +5,5 @@ NEXT_PUBLIC_NOTES_API_URL=http://localhost:4016/api NEXT_PUBLIC_EXTRACTION_SERVICE_URL=http://localhost:4005 NEXT_PUBLIC_DIAGNOSTICS_URL=http://localhost:3000 NEXT_PUBLIC_TELEMETRY_TRANSPORT=fetch +NEXT_PUBLIC_WEB_APP_ORIGIN=http://localhost:3000 +NEXT_PUBLIC_MCP_SERVER_URL=http://localhost:4050/mcp diff --git a/web/public/manifest.json b/web/public/manifest.json new file mode 100644 index 0000000..81775b9 --- /dev/null +++ b/web/public/manifest.json @@ -0,0 +1,10 @@ +{ + "name": "NoteLett", + "short_name": "NoteLett", + "description": "Structured notes for humans and AI agents", + "start_url": "/dashboard", + "display": "standalone", + "background_color": "#06070A", + "theme_color": "#06070A", + "icons": [] +} diff --git a/web/src/app/(app)/chat/page.tsx b/web/src/app/(app)/chat/page.tsx new file mode 100644 index 0000000..d318d3c --- /dev/null +++ b/web/src/app/(app)/chat/page.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { AppShell } from "@/components/AppShell"; +import { chatOverWorkspace, listWorkspaceSummaries } from "@/lib/notes-client"; +import type { WorkspaceSummary } from "@/lib/types"; +import { toast } from "@/lib/toast"; + +export default function ChatPage() { + const [workspaces, setWorkspaces] = useState([]); + const [workspaceId, setWorkspaceId] = useState(""); + const [message, setMessage] = useState(""); + const [answer, setAnswer] = useState(null); + const [citations, setCitations] = useState>([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + void listWorkspaceSummaries().then((w) => { + setWorkspaces(w); + setWorkspaceId((id) => id || w[0]?.id || ""); + }); + }, []); + + async function handleAsk() { + if (!workspaceId.trim() || !message.trim()) return; + setLoading(true); + setAnswer(null); + setCitations([]); + try { + const res = await chatOverWorkspace(workspaceId, message.trim()); + setAnswer(res.answer); + setCitations(res.citations); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Chat failed"); + } finally { + setLoading(false); + } + } + + return ( + Feature-flagged on backend (chat.rag_enabled)} + > +
+ +