feat: implement WEB_AI_FAST_ROADMAP (web + backend + docs)
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
This commit is contained in:
parent
1fa7e29691
commit
a697752d15
52
backend/src/lib/copilot-transform.ts
Normal file
52
backend/src/lib/copilot-transform.ts
Normal file
@ -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<string> {
|
||||
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<string> {
|
||||
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';
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
|
||||
93
backend/src/lib/note-search-rank.ts
Normal file
93
backend/src/lib/note-search-rank.ts
Normal file
@ -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;
|
||||
}
|
||||
31
backend/src/modules/note-shares/repository.ts
Normal file
31
backend/src/modules/note-shares/repository.ts
Normal file
@ -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<NoteShareDoc>('note_shares', '/workspaceId');
|
||||
}
|
||||
|
||||
export async function createNoteShare(doc: NoteShareDoc): Promise<NoteShareDoc> {
|
||||
return collection().create(doc);
|
||||
}
|
||||
|
||||
export async function findShareByToken(
|
||||
shareToken: string,
|
||||
productId: string,
|
||||
): Promise<NoteShareDoc | null> {
|
||||
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<NoteShareDoc[]> {
|
||||
const filter: FilterMap = { userId, productId, workspaceId, noteId };
|
||||
const items = await collection().findMany({ filter, sort: { createdAt: -1 }, limit: 20, offset: 0 });
|
||||
return items;
|
||||
}
|
||||
18
backend/src/modules/note-shares/types.ts
Normal file
18
backend/src/modules/note-shares/types.ts
Normal file
@ -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),
|
||||
});
|
||||
30
backend/src/modules/note-versions/repository.ts
Normal file
30
backend/src/modules/note-versions/repository.ts
Normal file
@ -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<NoteVersionDoc>('note_versions', '/workspaceId');
|
||||
}
|
||||
|
||||
export async function appendNoteVersion(doc: NoteVersionDoc): Promise<NoteVersionDoc> {
|
||||
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 };
|
||||
}
|
||||
21
backend/src/modules/note-versions/types.ts
Normal file
21
backend/src/modules/note-versions/types.ts
Normal file
@ -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),
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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<FastifyApp, 'setReadyState' | 'isReadyState'>;
|
||||
|
||||
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 };
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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: '<p>This is your knowledge base for humans and AI agents. Use <strong>Scan note for tasks</strong> on a note, try the command palette (<kbd>⌘K</kbd>), and review agent proposals in Reviews.</p>',
|
||||
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: '<h2>Retro</h2><ul><li>Ship command palette</li><li>Add context pack export</li><li>Review agent diffs</li></ul>',
|
||||
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: '<p>An agent suggested adding a citation block here. Approve or reject from the Reviews page.</p>',
|
||||
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 };
|
||||
|
||||
@ -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,
|
||||
|
||||
24
docs/DEEP_LINKS.md
Normal file
24
docs/DEEP_LINKS.md
Normal file
@ -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)
|
||||
@ -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. |
|
||||
|
||||
@ -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).
|
||||
@ -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
|
||||
|
||||
10
web/public/manifest.json
Normal file
10
web/public/manifest.json
Normal file
@ -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": []
|
||||
}
|
||||
93
web/src/app/(app)/chat/page.tsx
Normal file
93
web/src/app/(app)/chat/page.tsx
Normal file
@ -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<WorkspaceSummary[]>([]);
|
||||
const [workspaceId, setWorkspaceId] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [answer, setAnswer] = useState<string | null>(null);
|
||||
const [citations, setCitations] = useState<Array<{ noteId: string; title: string; snippet: string }>>([]);
|
||||
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 (
|
||||
<AppShell
|
||||
title="Workspace chat"
|
||||
description="Retrieval over your notes (citations below). This is not a general-purpose LLM — answers are assembled from indexed note text."
|
||||
actions={<span className="badge">Feature-flagged on backend (chat.rag_enabled)</span>}
|
||||
>
|
||||
<section className="surface-card" style={{ padding: "var(--nl-space-6)", display: "grid", gap: "var(--nl-space-4)", maxWidth: 720 }}>
|
||||
<label style={{ display: "grid", gap: 8 }}>
|
||||
<span style={{ fontWeight: 600 }}>Workspace</span>
|
||||
<select className="input-shell" value={workspaceId} onChange={(e) => setWorkspaceId(e.target.value)}>
|
||||
{workspaces.map((w) => (
|
||||
<option key={w.id} value={w.id}>
|
||||
{w.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label style={{ display: "grid", gap: 8 }}>
|
||||
<span style={{ fontWeight: 600 }}>Question</span>
|
||||
<textarea
|
||||
className="input-shell"
|
||||
rows={4}
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="What did we decide about launch?"
|
||||
/>
|
||||
</label>
|
||||
<button type="button" className="btn btn-primary" disabled={loading} onClick={() => void handleAsk()}>
|
||||
{loading ? "Thinking…" : "Ask"}
|
||||
</button>
|
||||
{answer ? (
|
||||
<div style={{ display: "grid", gap: "var(--nl-space-3)" }}>
|
||||
<strong>Answer</strong>
|
||||
<div style={{ whiteSpace: "pre-wrap", lineHeight: 1.6 }}>{answer}</div>
|
||||
{citations.length ? (
|
||||
<div style={{ display: "grid", gap: "var(--nl-space-2)" }}>
|
||||
<strong>Citations</strong>
|
||||
<ul style={{ margin: 0, paddingLeft: 20 }}>
|
||||
{citations.map((c) => (
|
||||
<li key={c.noteId} style={{ marginBottom: 8 }}>
|
||||
<Link href={`/notes/${c.noteId}`}>{c.title}</Link>
|
||||
<div style={{ color: "var(--nl-text-secondary)", fontSize: "var(--nl-fs-sm)" }}>{c.snippet}</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@ -1,62 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { CreateNoteModal } from "@/components/CreateNoteModal";
|
||||
import { listNoteSummaries, listWorkspaceSummaries } from "@/lib/notes-client";
|
||||
import { listNoteSummaries, listWorkspaceSummaries, seedOnboardingWorkspace } from "@/lib/notes-client";
|
||||
import { listApprovalQueue } from "@/lib/review-client";
|
||||
import { listSavedViews, type SavedView } from "@/lib/saved-views-client";
|
||||
import { toast } from "@/lib/toast";
|
||||
import type { NoteSummary, WorkspaceSummary } from "@/lib/types";
|
||||
|
||||
export default function DashboardPage() {
|
||||
function hrefForSavedView(view: SavedView): string {
|
||||
if (view.scope === "search") {
|
||||
return `/search?q=${encodeURIComponent(view.query)}`;
|
||||
}
|
||||
if (view.scope === "review") {
|
||||
return "/reviews";
|
||||
}
|
||||
return `/workspaces?q=${encodeURIComponent(view.query || "")}`;
|
||||
}
|
||||
|
||||
function DashboardContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [notes, setNotes] = useState<NoteSummary[]>([]);
|
||||
const [workspaces, setWorkspaces] = useState<WorkspaceSummary[]>([]);
|
||||
const [apiSavedViews, setApiSavedViews] = useState<SavedView[]>([]);
|
||||
const [pendingReviewCount, setPendingReviewCount] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showCreateNote, setShowCreateNote] = useState(false);
|
||||
const [seeding, setSeeding] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchParams.get("create") === "1") {
|
||||
setShowCreateNote(true);
|
||||
router.replace("/dashboard", { scroll: false });
|
||||
}
|
||||
}, [searchParams, router]);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const [nextNotes, nextWorkspaces, nextApprovalQueue] = await Promise.all([
|
||||
const [nextNotes, nextWorkspaces, nextApprovalQueue, saved] = await Promise.all([
|
||||
listNoteSummaries(),
|
||||
listWorkspaceSummaries(),
|
||||
listApprovalQueue(),
|
||||
listSavedViews().catch(() => [] as SavedView[]),
|
||||
]);
|
||||
setNotes(nextNotes);
|
||||
setWorkspaces(nextWorkspaces);
|
||||
setPendingReviewCount(nextApprovalQueue.length);
|
||||
setApiSavedViews(saved);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Unable to load dashboard data");
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const savedViews = [
|
||||
const quickLinks = [
|
||||
{
|
||||
id: "workspace-all",
|
||||
name: "All workspaces",
|
||||
scope: "workspace",
|
||||
description: "Current workspace inventory derived from backend-backed workspace data.",
|
||||
query: "visibility:any sort:updated",
|
||||
scope: "workspace" as const,
|
||||
description: "Workspace inventory from the backend.",
|
||||
query: "—",
|
||||
resultCount: workspaces.length,
|
||||
href: "/workspaces",
|
||||
},
|
||||
{
|
||||
id: "draft-notes",
|
||||
name: "Draft notes",
|
||||
scope: "search",
|
||||
description: "Draft notes currently tracked across the active knowledge base.",
|
||||
query: "status:draft",
|
||||
scope: "search" as const,
|
||||
description: "Notes in draft status.",
|
||||
query: "draft",
|
||||
resultCount: notes.filter((note) => note.status === "draft").length,
|
||||
href: `/search?q=${encodeURIComponent("draft")}`,
|
||||
},
|
||||
{
|
||||
id: "pending-review",
|
||||
name: "Pending review",
|
||||
scope: "review",
|
||||
description: "Current agent-mediated items awaiting review.",
|
||||
query: "state:draft|proposed",
|
||||
scope: "review" as const,
|
||||
description: "Agent-mediated items awaiting review.",
|
||||
query: "proposed|draft",
|
||||
resultCount: pendingReviewCount,
|
||||
href: "/reviews",
|
||||
},
|
||||
];
|
||||
|
||||
@ -81,18 +109,6 @@ export default function DashboardPage() {
|
||||
|
||||
const recentNotes = notes.slice(0, 3);
|
||||
|
||||
function getSavedViewHref(view: (typeof savedViews)[number]) {
|
||||
if (view.id === "workspace-all") {
|
||||
return "/workspaces";
|
||||
}
|
||||
|
||||
if (view.id === "draft-notes") {
|
||||
return `/search?q=${encodeURIComponent("draft")}`;
|
||||
}
|
||||
|
||||
return "/reviews";
|
||||
}
|
||||
|
||||
function getWorkflowHref(workflow: (typeof operatorWorkflows)[number]) {
|
||||
if (workflow.id === "workflow-workspaces") {
|
||||
return "/workspaces";
|
||||
@ -122,6 +138,8 @@ export default function DashboardPage() {
|
||||
},
|
||||
] as const;
|
||||
|
||||
const sortedSaved = [...apiSavedViews].sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
title="Dashboard"
|
||||
@ -141,14 +159,74 @@ export default function DashboardPage() {
|
||||
))}
|
||||
</section>
|
||||
|
||||
{workspaces.length === 0 ? (
|
||||
<section className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-3)" }}>
|
||||
<strong>Welcome — create a sample workspace</strong>
|
||||
<p style={{ margin: 0, color: "var(--nl-text-secondary)" }}>
|
||||
Seeds a "Getting started" workspace with sample notes and one agent item in the review queue (backend flag{" "}
|
||||
<code style={{ fontSize: "var(--nl-fs-sm)" }}>onboarding.seed_enabled</code>).
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={seeding}
|
||||
onClick={() => {
|
||||
setSeeding(true);
|
||||
void seedOnboardingWorkspace()
|
||||
.then(() => {
|
||||
toast.success("Sample workspace created");
|
||||
window.location.reload();
|
||||
})
|
||||
.catch((e) => toast.error(e instanceof Error ? e.message : "Seed failed"))
|
||||
.finally(() => setSeeding(false));
|
||||
}}
|
||||
>
|
||||
{seeding ? "Creating…" : "Seed sample workspace"}
|
||||
</button>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section style={{ display: "grid", gridTemplateColumns: "minmax(0, 1.1fr) minmax(320px, 0.9fr)", gap: "var(--nl-space-4)" }}>
|
||||
<section className="surface-card" style={{ padding: "var(--nl-space-6)", display: "grid", gap: "var(--nl-space-4)" }}>
|
||||
<div style={{ fontSize: "var(--nl-fs-xl)", fontWeight: 700 }}>Saved views</div>
|
||||
<p style={{ margin: 0, color: "var(--nl-text-secondary)", fontSize: "var(--nl-fs-sm)" }}>
|
||||
Views stored in your account (Search page can add more). Below that, quick links stay on the dashboard for one-tap access.
|
||||
</p>
|
||||
<div style={{ display: "grid", gap: "var(--nl-space-3)" }}>
|
||||
{savedViews.map((view) => (
|
||||
{sortedSaved.length === 0 ? (
|
||||
<div className="surface-muted" style={{ padding: "var(--nl-space-4)", color: "var(--nl-text-secondary)" }}>
|
||||
No saved views yet. Open <Link href="/search">Search</Link> and save a query to pin it here.
|
||||
</div>
|
||||
) : (
|
||||
sortedSaved.map((view) => (
|
||||
<Link
|
||||
key={view.id}
|
||||
href={hrefForSavedView(view)}
|
||||
className="surface-muted"
|
||||
style={{ padding: "var(--nl-space-4)", display: "grid", gap: "var(--nl-space-2)" }}
|
||||
>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", gap: "var(--nl-space-3)", flexWrap: "wrap" }}>
|
||||
<strong>{view.name}</strong>
|
||||
<span className="badge">{view.scope}</span>
|
||||
</div>
|
||||
{view.description ? (
|
||||
<div style={{ color: "var(--nl-text-secondary)" }}>{view.description}</div>
|
||||
) : null}
|
||||
<div style={{ display: "flex", justifyContent: "space-between", gap: "var(--nl-space-3)", flexWrap: "wrap" }}>
|
||||
<span style={{ color: "var(--nl-text-secondary)" }}>{view.query}</span>
|
||||
<span style={{ color: "var(--nl-text-secondary)" }}>Open →</span>
|
||||
</div>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: "var(--nl-fs-lg)", fontWeight: 700, marginTop: "var(--nl-space-2)" }}>Quick links</div>
|
||||
<div style={{ display: "grid", gap: "var(--nl-space-3)" }}>
|
||||
{quickLinks.map((view) => (
|
||||
<Link
|
||||
key={view.id}
|
||||
href={getSavedViewHref(view)}
|
||||
href={view.href}
|
||||
className="surface-muted"
|
||||
style={{ padding: "var(--nl-space-4)", display: "grid", gap: "var(--nl-space-2)" }}
|
||||
>
|
||||
@ -231,3 +309,17 @@ export default function DashboardPage() {
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<AppShell title="Dashboard" description="Loading…">
|
||||
<p style={{ color: "var(--nl-text-secondary)" }}>Loading dashboard…</p>
|
||||
</AppShell>
|
||||
}
|
||||
>
|
||||
<DashboardContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@ -3,10 +3,12 @@ import { AuthGuard } from "@/components/AuthGuard";
|
||||
import { KeyboardShortcuts } from "@/components/KeyboardShortcuts";
|
||||
import { BroadcastBanner } from "@/components/BroadcastBanner";
|
||||
import { SurveyBanner } from "@/components/SurveyBanner";
|
||||
import { CommandPalette } from "@/components/CommandPalette";
|
||||
|
||||
export default function ProductLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<AuthGuard>
|
||||
<CommandPalette />
|
||||
<KeyboardShortcuts />
|
||||
<BroadcastBanner />
|
||||
<SurveyBanner />
|
||||
|
||||
@ -8,12 +8,26 @@ import { NoteEditor } from "@/components/NoteEditor";
|
||||
import { MetadataPanel } from "@/components/MetadataPanel";
|
||||
import { LinkedNotesPanel } from "@/components/LinkedNotesPanel";
|
||||
import { TaskReviewPanel } from "@/components/TaskReviewPanel";
|
||||
import { ExtractedTasksPanel } from "@/components/ExtractedTasksPanel";
|
||||
import { ArtifactPanel } from "@/components/ArtifactPanel";
|
||||
import { AgentTimeline } from "@/components/AgentTimeline";
|
||||
import { LinkNoteModal } from "@/components/LinkNoteModal";
|
||||
import { archiveNote, createNoteArtifact, createNoteTask, getNoteDetail, restoreNote, summarizeNote, updateNoteDetail } from "@/lib/notes-client";
|
||||
import {
|
||||
archiveNote,
|
||||
createNoteArtifact,
|
||||
createNoteShare,
|
||||
createNoteTask,
|
||||
getNoteDetail,
|
||||
restoreNote,
|
||||
summarizeNote,
|
||||
updateNoteDetail,
|
||||
} from "@/lib/notes-client";
|
||||
import { buildNoteContextPack } from "@/lib/context-pack";
|
||||
import { getWebAppOrigin } from "@/lib/product-config";
|
||||
import { suggestNoteTitle } from "@/lib/copilot-client";
|
||||
import { toast } from "@/lib/toast";
|
||||
import type { NoteDetail } from "@/lib/types";
|
||||
import { NoteVersionsPanel } from "@/components/NoteVersionsPanel";
|
||||
|
||||
export default function NoteDetailPage() {
|
||||
const params = useParams<{ noteId: string }>();
|
||||
@ -35,7 +49,7 @@ export default function NoteDetailPage() {
|
||||
})();
|
||||
}, [noteId]);
|
||||
|
||||
async function handleSave(updates: { title: string; body: string }) {
|
||||
async function handleSave(updates: { title: string; body: string }, opts?: { quiet?: boolean }) {
|
||||
if (!note) {
|
||||
return;
|
||||
}
|
||||
@ -44,10 +58,12 @@ export default function NoteDetailPage() {
|
||||
|
||||
try {
|
||||
await updateNoteDetail(note.id, note.workspaceId, updates);
|
||||
const refreshed = await getNoteDetail(note.id);
|
||||
const refreshed = await getNoteDetail(note.id, note.workspaceId);
|
||||
setNote(refreshed);
|
||||
setError(null);
|
||||
toast.success("Note saved");
|
||||
if (!opts?.quiet) {
|
||||
toast.success("Note saved");
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : "Unable to save note";
|
||||
setError(msg);
|
||||
@ -79,7 +95,7 @@ export default function NoteDetailPage() {
|
||||
description: input.description,
|
||||
blobPath: input.blobPath,
|
||||
});
|
||||
const refreshed = await getNoteDetail(note.id);
|
||||
const refreshed = await getNoteDetail(note.id, note.workspaceId);
|
||||
setNote(refreshed);
|
||||
setError(null);
|
||||
toast.success("Artifact created");
|
||||
@ -108,7 +124,7 @@ export default function NoteDetailPage() {
|
||||
description: input.description,
|
||||
source: "manual",
|
||||
});
|
||||
const refreshed = await getNoteDetail(note.id);
|
||||
const refreshed = await getNoteDetail(note.id, note.workspaceId);
|
||||
setNote(refreshed);
|
||||
setError(null);
|
||||
toast.success("Task created");
|
||||
@ -154,6 +170,43 @@ export default function NoteDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopyContextPack() {
|
||||
if (!note) return;
|
||||
try {
|
||||
const md = buildNoteContextPack(note, { workspaceId: note.workspaceId });
|
||||
await navigator.clipboard.writeText(md);
|
||||
toast.success("Context pack copied");
|
||||
} catch {
|
||||
toast.error("Could not copy");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateShareLink() {
|
||||
if (!note) return;
|
||||
try {
|
||||
const { shareToken } = await createNoteShare(note.id, note.workspaceId);
|
||||
const url = `${getWebAppOrigin()}/share/${shareToken}`;
|
||||
await navigator.clipboard.writeText(url);
|
||||
toast.success("Share link copied");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Share failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSuggestTitle() {
|
||||
if (!note) return;
|
||||
try {
|
||||
const t = await suggestNoteTitle(note.id, note.workspaceId);
|
||||
if (typeof window !== "undefined" && window.confirm(`Use suggested title?\n\n${t}`)) {
|
||||
await updateNoteDetail(note.id, note.workspaceId, { title: t });
|
||||
setNote(await getNoteDetail(note.id, note.workspaceId));
|
||||
toast.success("Title updated");
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Suggestion failed");
|
||||
}
|
||||
}
|
||||
|
||||
if (!note) {
|
||||
return (
|
||||
<AppShell
|
||||
@ -184,6 +237,15 @@ export default function NoteDetailPage() {
|
||||
<button className="btn btn-secondary" onClick={handleSummarize}>
|
||||
Summarize
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => void handleSuggestTitle()}>
|
||||
Suggest title
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => void handleCopyContextPack()}>
|
||||
Copy context pack
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => void handleCreateShareLink()}>
|
||||
Copy share link
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={() => setShowLinkNote(true)}>
|
||||
Link Note
|
||||
</button>
|
||||
@ -196,12 +258,26 @@ export default function NoteDetailPage() {
|
||||
}
|
||||
>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 1.7fr) minmax(320px, 1fr)", gap: "var(--nl-space-4)" }}>
|
||||
<NoteEditor note={note} onSave={handleSave} isSaving={isSaving} />
|
||||
<NoteEditor
|
||||
note={note}
|
||||
onSave={handleSave}
|
||||
isSaving={isSaving}
|
||||
copilotNoteId={note.id}
|
||||
copilotWorkspaceId={note.workspaceId}
|
||||
/>
|
||||
|
||||
<aside style={{ display: "grid", gap: "var(--nl-space-4)" }}>
|
||||
{error ? <div className="surface-card" style={{ padding: "var(--nl-space-4)", color: "var(--nl-text-secondary)" }}>{error}</div> : null}
|
||||
<MetadataPanel note={note} />
|
||||
<NoteVersionsPanel noteId={note.id} workspaceId={note.workspaceId} />
|
||||
<LinkedNotesPanel linkedNotes={note.linkedNotes} />
|
||||
<ExtractedTasksPanel
|
||||
noteId={note.id}
|
||||
workspaceId={note.workspaceId}
|
||||
noteBody={note.body}
|
||||
persistedTasks={note.tasks}
|
||||
onTaskAccepted={() => void getNoteDetail(note.id, note.workspaceId).then((n) => n && setNote(n))}
|
||||
/>
|
||||
<TaskReviewPanel tasks={note.tasks} onCreate={handleCreateTask} isCreating={isCreatingTask} />
|
||||
<ArtifactPanel artifacts={note.artifacts} onCreate={handleCreateArtifact} isCreating={isCreatingArtifact} />
|
||||
<AgentTimeline items={note.timeline} />
|
||||
|
||||
@ -2,7 +2,7 @@ import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import SearchPage from "./page";
|
||||
|
||||
const searchNoteSummariesMock = vi.fn();
|
||||
const searchNotesRankedMock = vi.fn();
|
||||
const listSavedViewsMock = vi.fn();
|
||||
|
||||
vi.mock("next/link", () => ({
|
||||
@ -14,7 +14,7 @@ vi.mock("next/link", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/notes-client", () => ({
|
||||
searchNoteSummaries: () => searchNoteSummariesMock(),
|
||||
searchNotesRanked: () => searchNotesRankedMock(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/saved-views-client", () => ({
|
||||
@ -32,18 +32,20 @@ describe("SearchPage", () => {
|
||||
listSavedViewsMock.mockResolvedValue([
|
||||
{ id: "sv-1", name: "Launch readiness", scope: "search", query: "tag:launch", createdAt: "", updatedAt: "", sortOrder: 0 },
|
||||
]);
|
||||
searchNoteSummariesMock.mockResolvedValue([
|
||||
{
|
||||
id: "note-prd-cutline",
|
||||
workspaceId: "workspace-product",
|
||||
title: "MVP cut line for agentic notes launch",
|
||||
excerpt: "Define which note, task, search, and approval flows must exist before wider rollout.",
|
||||
status: "active",
|
||||
tags: ["mvp", "launch", "scope"],
|
||||
updatedAt: "2026-03-10T14:30:00.000Z",
|
||||
updatedBy: "Product Lead",
|
||||
},
|
||||
]);
|
||||
searchNotesRankedMock.mockResolvedValue({
|
||||
mode: "hybrid",
|
||||
total: 1,
|
||||
items: [
|
||||
{
|
||||
noteId: "note-prd-cutline",
|
||||
workspaceId: "workspace-product",
|
||||
title: "MVP cut line for agentic notes launch",
|
||||
score: 3,
|
||||
matchKind: "title",
|
||||
snippet: "Define which note, task, search, and approval flows must exist before wider rollout.",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<SearchPage />);
|
||||
|
||||
|
||||
@ -4,10 +4,9 @@ import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Suspense, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { searchNoteSummaries } from "@/lib/notes-client";
|
||||
import { searchNotesRanked, type SearchRankedHit } from "@/lib/notes-client";
|
||||
import { listSavedViews, createSavedView, deleteSavedView, type SavedView } from "@/lib/saved-views-client";
|
||||
import { useDebounce } from "@/lib/use-debounce";
|
||||
import type { NoteSummary } from "@/lib/types";
|
||||
|
||||
export default function SearchPage() {
|
||||
return (
|
||||
@ -19,9 +18,10 @@ export default function SearchPage() {
|
||||
|
||||
function SearchPageInner() {
|
||||
const searchParams = useSearchParams();
|
||||
const [notes, setNotes] = useState<NoteSummary[]>([]);
|
||||
const [hits, setHits] = useState<SearchRankedHit[]>([]);
|
||||
const [query, setQuery] = useState(() => searchParams?.get("q") ?? "");
|
||||
const debouncedQuery = useDebounce(query, 250);
|
||||
const [mode, setMode] = useState<"lexical" | "hybrid">("hybrid");
|
||||
const [savedViewsList, setSavedViewsList] = useState<SavedView[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@ -32,13 +32,14 @@ function SearchPageInner() {
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
setNotes(await searchNoteSummaries(debouncedQuery));
|
||||
const res = await searchNotesRanked(debouncedQuery, mode);
|
||||
setHits(res.items);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Unable to load notes");
|
||||
}
|
||||
})();
|
||||
}, [debouncedQuery]);
|
||||
}, [debouncedQuery, mode]);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
@ -74,18 +75,22 @@ function SearchPageInner() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const savedViews = useMemo(() => savedViewsList.map((view) => ({
|
||||
id: view.id,
|
||||
name: view.name,
|
||||
query: view.query,
|
||||
resultCount: 0,
|
||||
})), [savedViewsList]);
|
||||
const savedViews = useMemo(
|
||||
() =>
|
||||
savedViewsList.map((view) => ({
|
||||
id: view.id,
|
||||
name: view.name,
|
||||
query: view.query,
|
||||
resultCount: 0,
|
||||
})),
|
||||
[savedViewsList],
|
||||
);
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
title="Search"
|
||||
description="Lexical search, tag filtering, and retrieval entry points. Semantic ranking and explainability remain follow-up work."
|
||||
actions={<div className="badge">Backend-backed note search</div>}
|
||||
description="Lexical and hybrid ranked search with match hints (title, body, tag). Toggle modes to compare behavior."
|
||||
actions={<div className="badge">POST /notes/search</div>}
|
||||
>
|
||||
<section style={{ display: "grid", gridTemplateColumns: "minmax(260px, 320px) minmax(0, 1fr)", gap: "var(--nl-space-4)" }}>
|
||||
<aside className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-4)" }}>
|
||||
@ -116,8 +121,16 @@ function SearchPageInner() {
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { if (window.confirm('Remove this saved view?')) void handleDeleteSavedView(view.id); }}
|
||||
style={{ color: "var(--nl-text-secondary)", background: "none", border: "none", cursor: "pointer", fontSize: "0.75rem" }}
|
||||
onClick={() => {
|
||||
if (window.confirm("Remove this saved view?")) void handleDeleteSavedView(view.id);
|
||||
}}
|
||||
style={{
|
||||
color: "var(--nl-text-secondary)",
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
fontSize: "0.75rem",
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
@ -130,11 +143,15 @@ function SearchPageInner() {
|
||||
</div>
|
||||
|
||||
<div style={{ display: "grid", gap: "var(--nl-space-2)" }}>
|
||||
<strong>Retrieval filters</strong>
|
||||
<span style={{ color: "var(--nl-text-secondary)" }}>workspace:any</span>
|
||||
<span style={{ color: "var(--nl-text-secondary)" }}>status:active + draft</span>
|
||||
<span style={{ color: "var(--nl-text-secondary)" }}>relationship scope: linked + cited</span>
|
||||
<span style={{ color: "var(--nl-text-secondary)" }}>explainability: matched fields</span>
|
||||
<strong>Search mode</strong>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<input type="radio" name="smode" checked={mode === "hybrid"} onChange={() => setMode("hybrid")} />
|
||||
Hybrid ranked (default)
|
||||
</label>
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<input type="radio" name="smode" checked={mode === "lexical"} onChange={() => setMode("lexical")} />
|
||||
Lexical only
|
||||
</label>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@ -142,40 +159,30 @@ function SearchPageInner() {
|
||||
<input
|
||||
aria-label="Search notes"
|
||||
className="input-shell"
|
||||
placeholder="Search notes, tags, tasks, and linked context"
|
||||
placeholder="Search notes, tags, and body text"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
<div style={{ display: "flex", gap: "var(--nl-space-2)", flexWrap: "wrap" }}>
|
||||
<span className="badge">workspace:all</span>
|
||||
<span className="badge">status:active</span>
|
||||
<span className="badge">source:manual+agent</span>
|
||||
<span className="badge">matched:title+body</span>
|
||||
<span className="badge">mode:{mode}</span>
|
||||
<span className="badge">{hits.length} hits</span>
|
||||
</div>
|
||||
{error ? <div style={{ color: "var(--nl-text-secondary)" }}>{error}</div> : null}
|
||||
<div style={{ display: "grid", gap: "var(--nl-space-3)" }}>
|
||||
{notes.map((note) => (
|
||||
<div key={note.id} className="surface-muted" style={{ padding: "var(--nl-space-4)", display: "grid", gap: "var(--nl-space-2)" }}>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 1.5fr) repeat(3, minmax(100px, auto))", gap: "var(--nl-space-3)", alignItems: "start" }}>
|
||||
<Link href={`/notes/${note.id}`} style={{ display: "grid", gap: "var(--nl-space-2)" }}>
|
||||
<strong>{note.title}</strong>
|
||||
<span style={{ color: "var(--nl-text-secondary)" }}>{note.excerpt}</span>
|
||||
</Link>
|
||||
<Link href={`/search?q=${encodeURIComponent(note.status)}`} style={{ color: "var(--nl-text-secondary)" }}>
|
||||
{note.status}
|
||||
</Link>
|
||||
<span style={{ color: "var(--nl-text-secondary)" }}>{note.updatedBy}</span>
|
||||
<Link href={`/search?q=${encodeURIComponent(note.workspaceId.replace("workspace-", ""))}`} style={{ color: "var(--nl-text-secondary)" }}>
|
||||
{note.workspaceId.replace("workspace-", "")}
|
||||
{hits.map((hit) => (
|
||||
<div key={hit.noteId} className="surface-muted" style={{ padding: "var(--nl-space-4)", display: "grid", gap: "var(--nl-space-2)" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", gap: "var(--nl-space-3)", flexWrap: "wrap" }}>
|
||||
<Link href={`/notes/${hit.noteId}`} style={{ fontWeight: 700 }}>
|
||||
{hit.title}
|
||||
</Link>
|
||||
<span className="badge">
|
||||
{hit.matchKind} · score {hit.score}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "var(--nl-space-2)", flexWrap: "wrap" }}>
|
||||
{note.tags.map((tag) => (
|
||||
<Link key={tag} href={`/search?q=${encodeURIComponent(tag)}`} className="badge">
|
||||
#{tag}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ color: "var(--nl-text-secondary)", fontSize: "var(--nl-fs-sm)" }}>{hit.snippet}</div>
|
||||
<Link href={`/search?q=${encodeURIComponent(hit.workspaceId)}`} style={{ color: "var(--nl-text-secondary)", fontSize: "var(--nl-fs-sm)" }}>
|
||||
workspace: {hit.workspaceId}
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -6,6 +6,8 @@ import { useAuth } from "@/lib/auth";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { getFeedbackClient } from "@/lib/feedback-client";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { NOTES_API_URL, PLATFORM_SERVICE_URL, MCP_SERVER_URL, PRODUCT_ID } from "@/lib/product-config";
|
||||
import { getOfflineQueue } from "@/lib/offline-queue";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { theme, toggle } = useTheme();
|
||||
@ -115,6 +117,61 @@ export default function SettingsPage() {
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-3)", marginTop: "var(--nl-space-4)" }}>
|
||||
<strong>Connect your agent (MCP)</strong>
|
||||
<p style={{ margin: 0, color: "var(--nl-text-secondary)", fontSize: "var(--nl-fs-sm)" }}>
|
||||
Use your platform access token with the shared MCP server. Point tools at the NoteLett backend and product id{" "}
|
||||
<code>{PRODUCT_ID}</code>.
|
||||
</p>
|
||||
<pre
|
||||
className="surface-muted"
|
||||
style={{ margin: 0, padding: "var(--nl-space-3)", fontSize: "var(--nl-fs-sm)", overflow: "auto", whiteSpace: "pre-wrap" }}
|
||||
>
|
||||
{`Notes API base: ${NOTES_API_URL}
|
||||
Platform API: ${PLATFORM_SERVICE_URL}
|
||||
MCP server (example): ${MCP_SERVER_URL}
|
||||
|
||||
# Example Cursor / Claude MCP entry (adjust to your installer):
|
||||
# "mcpServers": {
|
||||
# "notelett": { "url": "${MCP_SERVER_URL}" }
|
||||
# }`}
|
||||
</pre>
|
||||
<p style={{ margin: 0, color: "var(--nl-text-secondary)", fontSize: "var(--nl-fs-sm)" }}>
|
||||
Deep links for tools: see <code>docs/DEEP_LINKS.md</code> in the repo.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-3)", marginTop: "var(--nl-space-4)" }}>
|
||||
<strong>API tokens for automation</strong>
|
||||
<p style={{ margin: 0, color: "var(--nl-text-secondary)", fontSize: "var(--nl-fs-sm)" }}>
|
||||
Scoped tokens for MCP or CI are provisioned through the ByteLyst platform when your tenant enables them. This web app does not yet expose
|
||||
create/revoke; use the platform admin or CLI when available.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-3)", marginTop: "var(--nl-space-4)" }}>
|
||||
<strong>Offline queue</strong>
|
||||
<p style={{ margin: 0, color: "var(--nl-text-secondary)", fontSize: "var(--nl-fs-sm)" }}>
|
||||
Failed writes are retried from local storage via <code>@bytelyst/offline-queue</code> (storage key{" "}
|
||||
<code>{`${PRODUCT_ID}_offline_queue`}</code>). Reload or return online to flush.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ justifySelf: "start" }}
|
||||
onClick={() => {
|
||||
try {
|
||||
getOfflineQueue();
|
||||
toast.success("Offline queue is available in this build");
|
||||
} catch {
|
||||
toast.error("Offline queue unavailable");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Verify offline queue
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{/* Feedback */}
|
||||
<section className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-3)", marginTop: "var(--nl-space-4)" }}>
|
||||
<strong>Send feedback</strong>
|
||||
|
||||
@ -6,6 +6,7 @@ import { Suspense, useEffect, useMemo, useState } from "react";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { CreateWorkspaceModal } from "@/components/CreateWorkspaceModal";
|
||||
import { exportNotes, listNoteSummaries, listWorkspaceSummaries, deleteWorkspace } from "@/lib/notes-client";
|
||||
import { buildWorkspaceContextPackMarkdown } from "@/lib/context-pack";
|
||||
import { toast } from "@/lib/toast";
|
||||
import type { NoteSummary, WorkspaceSummary } from "@/lib/types";
|
||||
|
||||
@ -49,6 +50,32 @@ function WorkspacesPageInner() {
|
||||
setLoadKey((k) => k + 1);
|
||||
}
|
||||
|
||||
async function downloadWorkspaceContextPack(workspaceId: string, workspaceName: string) {
|
||||
try {
|
||||
const raw = await exportNotes("json", workspaceId);
|
||||
const data = JSON.parse(typeof raw === "string" ? raw : JSON.stringify(raw)) as {
|
||||
notes?: Array<{ id: string; title: string; body: string; tags?: string[] }>;
|
||||
};
|
||||
const notes = (data.notes ?? []).slice(0, 50).map((n) => ({
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
body: n.body,
|
||||
tags: n.tags ?? [],
|
||||
}));
|
||||
const md = buildWorkspaceContextPackMarkdown({ workspaceId, workspaceName, notes });
|
||||
const blob = new Blob([md], { type: "text/markdown" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `notelett-context-${workspaceId.slice(0, 8)}.md`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success("Context pack downloaded");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Export failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string, name: string) {
|
||||
if (!confirm(`Delete workspace "${name}"? This cannot be undone.`)) return;
|
||||
try {
|
||||
@ -204,6 +231,14 @@ function WorkspacesPageInner() {
|
||||
<Link href={`/workspaces?q=${encodeURIComponent(workspace.owner)}`} style={{ color: "var(--nl-text-secondary)" }}>
|
||||
Owner: {workspace.owner}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ padding: "4px 10px", fontSize: "var(--nl-fs-sm)" }}
|
||||
onClick={() => void downloadWorkspaceContextPack(workspace.id, workspace.name)}
|
||||
>
|
||||
Context pack (.md)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(workspace.id, workspace.name)}
|
||||
style={{ padding: "4px 10px", fontSize: "var(--nl-fs-sm)", background: "rgba(255,110,110,0.12)", color: "var(--nl-danger, #FF6E6E)", border: "none", borderRadius: "var(--nl-radius-sm)" }}
|
||||
|
||||
@ -13,6 +13,12 @@ export const metadata: Metadata = {
|
||||
title: PRODUCT_NAME,
|
||||
description:
|
||||
"Structured notes workspace for humans and ByteLyst agents with search, review, and operational context.",
|
||||
manifest: "/manifest.json",
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: "black-translucent",
|
||||
title: PRODUCT_NAME,
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
|
||||
62
web/src/app/share/[token]/page.tsx
Normal file
62
web/src/app/share/[token]/page.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { NOTES_API_URL, PRODUCT_NAME } from "@/lib/product-config";
|
||||
|
||||
type PublicNote = {
|
||||
title: string;
|
||||
body: string;
|
||||
noteId: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export default function SharedNotePage() {
|
||||
const params = useParams<{ token: string }>();
|
||||
const token = params.token;
|
||||
const [note, setNote] = useState<PublicNote | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const base = NOTES_API_URL.replace(/\/$/, "");
|
||||
const res = await fetch(`${base}/public/note-shares/${encodeURIComponent(token)}`);
|
||||
if (!res.ok) {
|
||||
setError("This share link is invalid or no longer available.");
|
||||
return;
|
||||
}
|
||||
const data = (await res.json()) as PublicNote;
|
||||
setNote(data);
|
||||
} catch {
|
||||
setError("Could not load shared note.");
|
||||
}
|
||||
})();
|
||||
}, [token]);
|
||||
|
||||
return (
|
||||
<div style={{ minHeight: "100vh", background: "var(--nl-bg-base, #06070A)", color: "var(--nl-text-primary)", padding: "var(--nl-space-8)" }}>
|
||||
<header style={{ maxWidth: 720, margin: "0 auto var(--nl-space-6)" }}>
|
||||
<div className="badge" style={{ marginBottom: 12 }}>
|
||||
Read-only share · {PRODUCT_NAME}
|
||||
</div>
|
||||
<h1 style={{ margin: 0, fontSize: "var(--nl-fs-2xl)" }}>{note?.title ?? (error ? "Unavailable" : "Loading…")}</h1>
|
||||
</header>
|
||||
<main id="main-content" className="surface-card" style={{ maxWidth: 720, margin: "0 auto", padding: "var(--nl-space-6)" }}>
|
||||
{error ? <p style={{ color: "var(--nl-text-secondary)" }}>{error}</p> : null}
|
||||
{note ? (
|
||||
<>
|
||||
<p style={{ color: "var(--nl-text-secondary)", fontSize: "var(--nl-fs-sm)" }}>
|
||||
Updated {new Date(note.updatedAt).toLocaleString()} · Note ID {note.noteId}
|
||||
</p>
|
||||
<div
|
||||
className="input-shell"
|
||||
style={{ marginTop: 16, minHeight: 200, lineHeight: 1.7 }}
|
||||
dangerouslySetInnerHTML={{ __html: note.body }}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
242
web/src/components/CommandPalette.tsx
Normal file
242
web/src/components/CommandPalette.tsx
Normal file
@ -0,0 +1,242 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useKeyboardShortcuts } from "@/lib/use-keyboard-shortcuts";
|
||||
import { listNoteSummaries, listWorkspaceSummaries } from "@/lib/notes-client";
|
||||
import type { NoteSummary, WorkspaceSummary } from "@/lib/types";
|
||||
|
||||
type PaletteItem =
|
||||
| { kind: "action"; id: string; label: string; hint?: string; href: string }
|
||||
| { kind: "note"; id: string; label: string; hint?: string; href: string }
|
||||
| { kind: "workspace"; id: string; label: string; hint?: string; href: string };
|
||||
|
||||
const STATIC_ACTIONS: PaletteItem[] = [
|
||||
{ kind: "action", id: "a-dash", label: "Dashboard", hint: "Home", href: "/dashboard" },
|
||||
{ kind: "action", id: "a-search", label: "Search", hint: "Find notes", href: "/search" },
|
||||
{ kind: "action", id: "a-reviews", label: "Reviews", hint: "Agent approvals", href: "/reviews" },
|
||||
{ kind: "action", id: "a-ws", label: "Workspaces", hint: "All workspaces", href: "/workspaces" },
|
||||
{ kind: "action", id: "a-settings", label: "Settings", hint: "Account", href: "/settings" },
|
||||
{ kind: "action", id: "a-new", label: "New note", hint: "Open create flow", href: "/dashboard?create=1" },
|
||||
];
|
||||
|
||||
function normalize(s: string): string {
|
||||
return s.toLowerCase().trim();
|
||||
}
|
||||
|
||||
function matchesQuery(haystack: string, q: string): boolean {
|
||||
const n = normalize(haystack);
|
||||
const words = normalize(q).split(/\s+/).filter(Boolean);
|
||||
if (words.length === 0) return true;
|
||||
return words.every((w) => n.includes(w));
|
||||
}
|
||||
|
||||
export function CommandPalette() {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [notes, setNotes] = useState<NoteSummary[]>([]);
|
||||
const [workspaces, setWorkspaces] = useState<WorkspaceSummary[]>([]);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const loadData = useCallback(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const [n, w] = await Promise.all([listNoteSummaries(), listWorkspaceSummaries()]);
|
||||
setNotes(n);
|
||||
setWorkspaces(w);
|
||||
setLoadError(null);
|
||||
} catch (e) {
|
||||
setLoadError(e instanceof Error ? e.message : "Load failed");
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadData();
|
||||
setQuery("");
|
||||
setActiveIndex(0);
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
}
|
||||
}, [open, loadData]);
|
||||
|
||||
const items = useMemo<PaletteItem[]>(() => {
|
||||
const q = query;
|
||||
const out: PaletteItem[] = [];
|
||||
|
||||
for (const a of STATIC_ACTIONS) {
|
||||
const text = `${a.label} ${a.hint ?? ""}`;
|
||||
if (matchesQuery(text, q)) {
|
||||
out.push({ ...a });
|
||||
}
|
||||
}
|
||||
|
||||
for (const w of workspaces) {
|
||||
const text = `${w.name} ${w.description}`;
|
||||
if (matchesQuery(text, q)) {
|
||||
out.push({
|
||||
kind: "workspace",
|
||||
id: `w-${w.id}`,
|
||||
label: w.name,
|
||||
hint: "Workspace",
|
||||
href: `/workspaces?q=${encodeURIComponent(w.name)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const n of notes) {
|
||||
const text = `${n.title} ${n.tags.join(" ")}`;
|
||||
if (matchesQuery(text, q)) {
|
||||
out.push({
|
||||
kind: "note",
|
||||
id: `n-${n.id}`,
|
||||
label: n.title,
|
||||
hint: "Note",
|
||||
href: `/notes/${n.id}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return out.slice(0, 50);
|
||||
}, [query, notes, workspaces]);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveIndex(0);
|
||||
}, [query, items.length]);
|
||||
|
||||
const go = useCallback(
|
||||
(href: string) => {
|
||||
setOpen(false);
|
||||
router.push(href);
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
useKeyboardShortcuts(
|
||||
useMemo(
|
||||
() => [
|
||||
{
|
||||
key: "k",
|
||||
meta: true,
|
||||
handler: () => setOpen((o) => !o),
|
||||
description: "Command palette",
|
||||
},
|
||||
{
|
||||
key: "Escape",
|
||||
handler: () => setOpen(false),
|
||||
description: "Close palette",
|
||||
},
|
||||
],
|
||||
[],
|
||||
),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => Math.min(i + 1, Math.max(0, items.length - 1)));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActiveIndex((i) => Math.max(i - 1, 0));
|
||||
} else if (e.key === "Enter" && items[activeIndex]) {
|
||||
e.preventDefault();
|
||||
go(items[activeIndex].href);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [open, items, activeIndex, go]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Command palette"
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 200,
|
||||
background: "rgba(0,0,0,0.55)",
|
||||
display: "grid",
|
||||
placeItems: "start center",
|
||||
paddingTop: "12vh",
|
||||
paddingInline: "var(--nl-space-4)",
|
||||
}}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<div
|
||||
className="surface-card"
|
||||
style={{
|
||||
width: "min(560px, 100%)",
|
||||
maxHeight: "70vh",
|
||||
overflow: "hidden",
|
||||
display: "grid",
|
||||
gridTemplateRows: "auto 1fr",
|
||||
boxShadow: "0 24px 80px rgba(0,0,0,0.45)",
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div style={{ padding: "var(--nl-space-3) var(--nl-space-4)", borderBottom: "1px solid var(--nl-border-default)" }}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="input-shell"
|
||||
placeholder="Jump to note, workspace, or action…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
style={{ width: "100%", fontSize: "var(--nl-fs-md)" }}
|
||||
/>
|
||||
<div style={{ marginTop: 8, fontSize: "var(--nl-fs-sm)", color: "var(--nl-text-secondary)" }}>
|
||||
<kbd style={{ opacity: 0.8 }}>↑</kbd> <kbd style={{ opacity: 0.8 }}>↓</kbd> navigate · <kbd style={{ opacity: 0.8 }}>↵</kbd> open ·{" "}
|
||||
<kbd style={{ opacity: 0.8 }}>esc</kbd> close
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ overflow: "auto", padding: "var(--nl-space-2)" }}>
|
||||
{loadError ? (
|
||||
<div style={{ padding: "var(--nl-space-4)", color: "var(--nl-text-secondary)" }}>{loadError}</div>
|
||||
) : items.length === 0 ? (
|
||||
<div style={{ padding: "var(--nl-space-4)", color: "var(--nl-text-secondary)" }}>No matches.</div>
|
||||
) : (
|
||||
<ul style={{ listStyle: "none", margin: 0, padding: 0, display: "grid", gap: 4 }}>
|
||||
{items.map((item, idx) => (
|
||||
<li key={item.id}>
|
||||
<Link
|
||||
href={item.href}
|
||||
onClick={() => setOpen(false)}
|
||||
style={{
|
||||
display: "block",
|
||||
padding: "10px 12px",
|
||||
borderRadius: "var(--nl-radius-md)",
|
||||
textDecoration: "none",
|
||||
color: "var(--nl-text-primary)",
|
||||
background: idx === activeIndex ? "rgba(90,140,255,0.2)" : "transparent",
|
||||
border: idx === activeIndex ? "1px solid var(--nl-accent-primary)" : "1px solid transparent",
|
||||
}}
|
||||
onMouseEnter={() => setActiveIndex(idx)}
|
||||
>
|
||||
<div style={{ fontWeight: 600 }}>{item.label}</div>
|
||||
<div style={{ fontSize: "var(--nl-fs-sm)", color: "var(--nl-text-secondary)" }}>
|
||||
<span className="badge" style={{ marginRight: 8 }}>
|
||||
{item.kind}
|
||||
</span>
|
||||
{item.hint}
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { createNote } from "@/lib/notes-client";
|
||||
import { NOTE_TEMPLATES } from "@/lib/note-templates";
|
||||
import type { WorkspaceSummary } from "@/lib/types";
|
||||
|
||||
interface CreateNoteModalProps {
|
||||
@ -16,9 +17,19 @@ export function CreateNoteModal({ workspaces, defaultWorkspaceId, onCreated, onC
|
||||
const [body, setBody] = useState("");
|
||||
const [workspaceId, setWorkspaceId] = useState(defaultWorkspaceId ?? workspaces[0]?.id ?? "");
|
||||
const [tags, setTags] = useState("");
|
||||
const [templateId, setTemplateId] = useState<string>("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function applyTemplate(id: string) {
|
||||
setTemplateId(id);
|
||||
const t = NOTE_TEMPLATES.find((x) => x.id === id);
|
||||
if (t) {
|
||||
setTitle(t.title);
|
||||
setBody(t.body);
|
||||
}
|
||||
}
|
||||
|
||||
const canSubmit = title.trim().length > 0 && body.trim().length > 0 && workspaceId.length > 0;
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
@ -33,7 +44,7 @@ export function CreateNoteModal({ workspaces, defaultWorkspaceId, onCreated, onC
|
||||
id: crypto.randomUUID(),
|
||||
workspaceId,
|
||||
title: title.trim(),
|
||||
body: body.trim(),
|
||||
body: body.includes("<") && body.includes(">") ? body.trim() : `<p>${body.trim()}</p>`,
|
||||
tags: tags
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
@ -79,6 +90,29 @@ export function CreateNoteModal({ workspaces, defaultWorkspaceId, onCreated, onC
|
||||
|
||||
{error && <div style={{ color: "var(--nl-danger, #e53e3e)", fontSize: "0.875rem" }}>{error}</div>}
|
||||
|
||||
<label style={{ display: "grid", gap: "var(--nl-space-1, 0.25rem)" }}>
|
||||
<span style={{ fontWeight: 600, fontSize: "0.875rem" }}>Template</span>
|
||||
<select
|
||||
className="input"
|
||||
value={templateId}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (!v) {
|
||||
setTemplateId("");
|
||||
return;
|
||||
}
|
||||
applyTemplate(v);
|
||||
}}
|
||||
>
|
||||
<option value="">Blank</option>
|
||||
{NOTE_TEMPLATES.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label style={{ display: "grid", gap: "var(--nl-space-1, 0.25rem)" }}>
|
||||
<span style={{ fontWeight: 600, fontSize: "0.875rem" }}>Workspace</span>
|
||||
<select value={workspaceId} onChange={(e) => setWorkspaceId(e.target.value)} className="input">
|
||||
|
||||
120
web/src/components/ExtractedTasksPanel.tsx
Normal file
120
web/src/components/ExtractedTasksPanel.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { extractSuggestedTasks } from "@/lib/extraction-client";
|
||||
import { createNoteTask } from "@/lib/notes-client";
|
||||
import { toast } from "@/lib/toast";
|
||||
import type { NoteTask } from "@/lib/types";
|
||||
|
||||
export function ExtractedTasksPanel({
|
||||
noteId,
|
||||
workspaceId,
|
||||
noteBody,
|
||||
persistedTasks,
|
||||
onTaskAccepted,
|
||||
}: {
|
||||
noteId: string;
|
||||
workspaceId: string;
|
||||
noteBody: string;
|
||||
persistedTasks: NoteTask[];
|
||||
onTaskAccepted: () => void;
|
||||
}) {
|
||||
const [proposals, setProposals] = useState<NoteTask[]>([]);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [acceptingId, setAcceptingId] = useState<string | null>(null);
|
||||
|
||||
const persistedTitles = useMemo(
|
||||
() => new Set(persistedTasks.map((t) => t.title.trim().toLowerCase())),
|
||||
[persistedTasks],
|
||||
);
|
||||
|
||||
const handleScan = useCallback(async () => {
|
||||
setScanning(true);
|
||||
try {
|
||||
const extracted = await extractSuggestedTasks(noteBody);
|
||||
const filtered = extracted.filter((t) => !persistedTitles.has(t.title.trim().toLowerCase()));
|
||||
setProposals(filtered);
|
||||
if (filtered.length === 0) {
|
||||
toast.success("No new suggested tasks found");
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Scan failed");
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
}, [noteBody, persistedTitles]);
|
||||
|
||||
const handleAccept = useCallback(
|
||||
async (task: NoteTask) => {
|
||||
setAcceptingId(task.id);
|
||||
try {
|
||||
await createNoteTask({
|
||||
id: crypto.randomUUID(),
|
||||
workspaceId,
|
||||
noteId,
|
||||
title: task.title,
|
||||
source: "extracted",
|
||||
});
|
||||
setProposals((p) => p.filter((x) => x.id !== task.id));
|
||||
toast.success("Task added");
|
||||
onTaskAccepted();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Could not create task");
|
||||
} finally {
|
||||
setAcceptingId(null);
|
||||
}
|
||||
},
|
||||
[noteId, workspaceId, onTaskAccepted],
|
||||
);
|
||||
|
||||
const handleDismiss = useCallback((taskId: string) => {
|
||||
setProposals((p) => p.filter((x) => x.id !== taskId));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-3)" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", gap: "var(--nl-space-3)", alignItems: "center", flexWrap: "wrap" }}>
|
||||
<div style={{ fontWeight: 700 }}>Suggested tasks (AI)</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={scanning}
|
||||
onClick={() => void handleScan()}
|
||||
>
|
||||
{scanning ? "Scanning…" : "Scan note for tasks"}
|
||||
</button>
|
||||
</div>
|
||||
<p style={{ margin: 0, color: "var(--nl-text-secondary)", fontSize: "var(--nl-fs-sm)" }}>
|
||||
Runs extraction on demand. Accept adds a backend task; dismiss only hides the suggestion for this session.
|
||||
</p>
|
||||
{proposals.length === 0 ? null : (
|
||||
<ul style={{ listStyle: "none", margin: 0, padding: 0, display: "grid", gap: "var(--nl-space-2)" }}>
|
||||
{proposals.map((task) => (
|
||||
<li key={task.id} className="surface-muted" style={{ padding: "var(--nl-space-3)", display: "flex", gap: "var(--nl-space-2)", flexWrap: "wrap", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<span>{task.title}</span>
|
||||
<span style={{ display: "flex", gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
style={{ padding: "6px 10px", fontSize: "var(--nl-fs-sm)" }}
|
||||
disabled={acceptingId === task.id}
|
||||
onClick={() => void handleAccept(task)}
|
||||
>
|
||||
{acceptingId === task.id ? "Adding…" : "Accept"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ padding: "6px 10px", fontSize: "var(--nl-fs-sm)" }}
|
||||
onClick={() => handleDismiss(task.id)}
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -9,17 +9,11 @@ export function KeyboardShortcuts() {
|
||||
|
||||
const shortcuts = useMemo<KeyboardShortcut[]>(
|
||||
() => [
|
||||
{
|
||||
key: "k",
|
||||
meta: true,
|
||||
handler: () => router.push("/search"),
|
||||
description: "Open search",
|
||||
},
|
||||
{
|
||||
key: "n",
|
||||
meta: true,
|
||||
handler: () => router.push("/workspaces"),
|
||||
description: "Open workspaces (new note)",
|
||||
description: "Open workspaces",
|
||||
},
|
||||
{
|
||||
key: "d",
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useEffect, useState, useCallback, useMemo, useRef } from "react";
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import type { NoteDetail } from "@/lib/types";
|
||||
import { useDebounce } from "@/lib/use-debounce";
|
||||
import { copilotTransform, type CopilotAction } from "@/lib/copilot-client";
|
||||
import { toast } from "@/lib/toast";
|
||||
|
||||
const TOOLBAR_BTN: React.CSSProperties = {
|
||||
border: "none",
|
||||
@ -34,12 +37,24 @@ export function NoteEditor({
|
||||
note,
|
||||
onSave,
|
||||
isSaving = false,
|
||||
autoSave = true,
|
||||
autoSaveDelayMs = 1500,
|
||||
copilotNoteId,
|
||||
copilotWorkspaceId,
|
||||
}: {
|
||||
note: NoteDetail;
|
||||
onSave: (updates: { title: string; body: string }) => Promise<void>;
|
||||
onSave: (updates: { title: string; body: string }, options?: { quiet?: boolean }) => Promise<void>;
|
||||
isSaving?: boolean;
|
||||
autoSave?: boolean;
|
||||
autoSaveDelayMs?: number;
|
||||
copilotNoteId?: string;
|
||||
copilotWorkspaceId?: string;
|
||||
}) {
|
||||
const [title, setTitle] = useState(note.title);
|
||||
const [, setBodyTick] = useState(0);
|
||||
const [copilotBusy, setCopilotBusy] = useState(false);
|
||||
const onSaveRef = useRef(onSave);
|
||||
onSaveRef.current = onSave;
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
@ -55,8 +70,11 @@ export function NoteEditor({
|
||||
style: "min-height:360px;outline:none;line-height:1.7;",
|
||||
},
|
||||
},
|
||||
onUpdate: () => setBodyTick((t) => t + 1),
|
||||
});
|
||||
|
||||
const bodyHtml = editor?.getHTML() ?? note.body;
|
||||
|
||||
useEffect(() => {
|
||||
setTitle(note.title);
|
||||
if (editor && note.body !== editor.getHTML()) {
|
||||
@ -64,11 +82,52 @@ export function NoteEditor({
|
||||
}
|
||||
}, [note.title, note.body, editor]);
|
||||
|
||||
const debouncedTitle = useDebounce(title, autoSaveDelayMs);
|
||||
const debouncedBody = useDebounce(bodyHtml, autoSaveDelayMs);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoSave || !editor || isSaving) return;
|
||||
if (debouncedTitle === note.title && debouncedBody === note.body) return;
|
||||
void onSaveRef.current({ title: debouncedTitle, body: debouncedBody }, { quiet: true });
|
||||
}, [autoSave, autoSaveDelayMs, debouncedTitle, debouncedBody, note.title, note.body, note.id, editor, isSaving]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (!editor) return;
|
||||
void onSave({ title, body: editor.getHTML() });
|
||||
void onSave({ title, body: editor.getHTML() }, { quiet: false });
|
||||
}, [editor, title, onSave]);
|
||||
|
||||
const dirty = useMemo(
|
||||
() => title !== note.title || bodyHtml !== note.body,
|
||||
[title, bodyHtml, note.title, note.body],
|
||||
);
|
||||
|
||||
const runCopilot = useCallback(
|
||||
async (action: CopilotAction) => {
|
||||
if (!editor || !copilotNoteId || !copilotWorkspaceId) return;
|
||||
const { from, to } = editor.state.selection;
|
||||
const selected = editor.state.doc.textBetween(from, to, "\n").trim();
|
||||
if (!selected) {
|
||||
toast.error("Select text in the editor first");
|
||||
return;
|
||||
}
|
||||
setCopilotBusy(true);
|
||||
try {
|
||||
const out = await copilotTransform(copilotNoteId, copilotWorkspaceId, action, selected);
|
||||
const escaped = out
|
||||
.split("\n")
|
||||
.map((line) => line.replace(/</g, "<").replace(/>/g, ">"))
|
||||
.join("</p><p>");
|
||||
editor.chain().focus().deleteSelection().insertContent(`<p>${escaped}</p>`).run();
|
||||
toast.success("Copilot insert applied — review and save");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Copilot failed");
|
||||
} finally {
|
||||
setCopilotBusy(false);
|
||||
}
|
||||
},
|
||||
[editor, copilotNoteId, copilotWorkspaceId],
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="surface-card" style={{ padding: "var(--nl-space-6)", display: "grid", gap: "var(--nl-space-4)" }}>
|
||||
<form
|
||||
@ -108,10 +167,30 @@ export function NoteEditor({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editor && copilotNoteId && copilotWorkspaceId ? (
|
||||
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", alignItems: "center", padding: "4px 0" }}>
|
||||
<span style={{ color: "var(--nl-text-secondary)", fontSize: "var(--nl-fs-sm)", marginRight: 4 }}>Copilot</span>
|
||||
{(["shorten", "bulletize", "expand", "grammar"] as const).map((a) => (
|
||||
<button
|
||||
key={a}
|
||||
type="button"
|
||||
disabled={copilotBusy}
|
||||
style={{ ...TOOLBAR_BTN, opacity: copilotBusy ? 0.5 : 1 }}
|
||||
onClick={() => void runCopilot(a)}
|
||||
>
|
||||
{a}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", justifyContent: "flex-end" }}>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", flexWrap: "wrap", gap: "var(--nl-space-3)" }}>
|
||||
<div style={{ fontSize: "var(--nl-fs-sm)", color: "var(--nl-text-secondary)" }} aria-live="polite">
|
||||
{isSaving ? "Saving…" : dirty ? "Unsaved changes (auto-save on pause)" : "All changes saved"}
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
@ -124,7 +203,7 @@ export function NoteEditor({
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{isSaving ? "Saving…" : "Save note"}
|
||||
{isSaving ? "Saving…" : "Save now"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
80
web/src/components/NoteVersionsPanel.tsx
Normal file
80
web/src/components/NoteVersionsPanel.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { listNoteVersions, type NoteVersionRow } from "@/lib/notes-client";
|
||||
|
||||
export function NoteVersionsPanel({ noteId, workspaceId }: { noteId: string; workspaceId: string }) {
|
||||
const [items, setItems] = useState<NoteVersionRow[]>([]);
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await listNoteVersions(noteId, workspaceId);
|
||||
setItems(res.items);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Could not load versions");
|
||||
}
|
||||
})();
|
||||
}, [noteId, workspaceId]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<section className="surface-card" style={{ padding: "var(--nl-space-4)", color: "var(--nl-text-secondary)" }}>
|
||||
{error}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<section className="surface-card" style={{ padding: "var(--nl-space-4)", color: "var(--nl-text-secondary)" }}>
|
||||
No saved versions yet. Versions are created when you edit title or body.
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-3)" }}>
|
||||
<div style={{ fontWeight: 700 }}>Version history</div>
|
||||
<ul style={{ listStyle: "none", margin: 0, padding: 0, display: "grid", gap: "var(--nl-space-2)" }}>
|
||||
{items.map((v) => (
|
||||
<li key={v.id} className="surface-muted" style={{ padding: "var(--nl-space-3)" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpenId((o) => (o === v.id ? null : v.id))}
|
||||
style={{
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
background: "none",
|
||||
border: "none",
|
||||
color: "var(--nl-text-primary)",
|
||||
cursor: "pointer",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{new Date(v.savedAt).toLocaleString()} · {v.source} · {v.title.slice(0, 48)}
|
||||
{v.title.length > 48 ? "…" : ""}
|
||||
</button>
|
||||
{openId === v.id ? (
|
||||
<pre
|
||||
style={{
|
||||
marginTop: 8,
|
||||
whiteSpace: "pre-wrap",
|
||||
fontSize: "var(--nl-fs-sm)",
|
||||
color: "var(--nl-text-secondary)",
|
||||
maxHeight: 200,
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
{v.body.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim()}
|
||||
</pre>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { House, Search, Settings, Sparkles, FolderKanban, ShieldCheck } from "lucide-react";
|
||||
import { House, Search, Settings, Sparkles, FolderKanban, ShieldCheck, MessageCircle } from "lucide-react";
|
||||
import { PRODUCT_NAME } from "@/lib/product-config";
|
||||
import { isFeatureEnabled } from "@/lib/feature-flags";
|
||||
|
||||
@ -11,6 +11,7 @@ const navItems: { href: string; label: string; icon: typeof House; flag?: string
|
||||
{ href: "/workspaces", label: "Workspaces", icon: FolderKanban },
|
||||
{ href: "/reviews", label: "Reviews", icon: ShieldCheck, flag: "mcp_tools_enabled" },
|
||||
{ href: "/search", label: "Search", icon: Search },
|
||||
{ href: "/chat", label: "Workspace chat", icon: MessageCircle },
|
||||
{ href: "/settings", label: "Settings", icon: Settings },
|
||||
];
|
||||
|
||||
@ -61,6 +62,9 @@ export function Sidebar({ open }: { open?: boolean }) {
|
||||
|
||||
<div className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-2)" }}>
|
||||
<strong>Keyboard flow</strong>
|
||||
<span style={{ color: "var(--nl-text-secondary)" }}>
|
||||
<kbd style={{ opacity: 0.85 }}>⌘K</kbd> / <kbd style={{ opacity: 0.85 }}>Ctrl+K</kbd> command palette
|
||||
</span>
|
||||
<span style={{ color: "var(--nl-text-secondary)" }}>Use Tab to move between navigation, filters, and dense result surfaces.</span>
|
||||
<span style={{ color: "var(--nl-text-secondary)" }}>Use the skip link to jump directly into page content.</span>
|
||||
</div>
|
||||
|
||||
38
web/src/lib/context-pack.test.ts
Normal file
38
web/src/lib/context-pack.test.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildFrontmatter, buildNoteContextPack, NOTELETT_CONTEXT_VERSION } from "@/lib/context-pack";
|
||||
import type { NoteDetail } from "@/lib/types";
|
||||
|
||||
const sampleNote: NoteDetail = {
|
||||
id: "n1",
|
||||
workspaceId: "w1",
|
||||
title: "Hello",
|
||||
excerpt: "Hi",
|
||||
status: "active",
|
||||
tags: ["a", "b"],
|
||||
updatedAt: "2026-03-31T00:00:00.000Z",
|
||||
updatedBy: "u1",
|
||||
body: "<p>Body <strong>text</strong></p>",
|
||||
metadata: { owner: "u1", source: "manual", reviewState: "none", taskCount: 0, artifactCount: 0 },
|
||||
linkedNotes: [{ id: "n2", title: "Other", relationship: "see_also" }],
|
||||
tasks: [{ id: "t1", title: "Do thing", status: "todo", source: "manual" }],
|
||||
artifacts: [],
|
||||
timeline: [],
|
||||
};
|
||||
|
||||
describe("context-pack", () => {
|
||||
it("builds stable frontmatter", () => {
|
||||
const fm = buildFrontmatter("ws-1", "2026-03-31T12:00:00.000Z");
|
||||
expect(fm).toContain(`notelett_version: "${NOTELETT_CONTEXT_VERSION}"`);
|
||||
expect(fm).toContain('workspace_id: "ws-1"');
|
||||
expect(fm).toContain("exported_at:");
|
||||
});
|
||||
|
||||
it("includes title, stripped body, links, and tasks", () => {
|
||||
const pack = buildNoteContextPack(sampleNote, { workspaceId: "w1", exportedAt: "2026-03-31T12:00:00.000Z" });
|
||||
expect(pack).toContain("# Hello");
|
||||
expect(pack).toContain("Body text");
|
||||
expect(pack).toContain("Linked notes");
|
||||
expect(pack).toContain("/notes/n2");
|
||||
expect(pack).toContain("Do thing");
|
||||
});
|
||||
});
|
||||
76
web/src/lib/context-pack.ts
Normal file
76
web/src/lib/context-pack.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import type { NoteDetail } from "@/lib/types";
|
||||
|
||||
export const NOTELETT_CONTEXT_VERSION = "1";
|
||||
|
||||
export interface ContextPackOptions {
|
||||
workspaceId: string;
|
||||
exportedAt?: string;
|
||||
includeTasks?: boolean;
|
||||
}
|
||||
|
||||
function yamlEscape(s: string): string {
|
||||
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n");
|
||||
}
|
||||
|
||||
/** YAML frontmatter for LLM / agent tooling parsers. */
|
||||
export function buildFrontmatter(workspaceId: string, exportedAt: string): string {
|
||||
return [
|
||||
"---",
|
||||
`notelett_version: "${NOTELETT_CONTEXT_VERSION}"`,
|
||||
`workspace_id: "${yamlEscape(workspaceId)}"`,
|
||||
`exported_at: "${yamlEscape(exportedAt)}"`,
|
||||
"---",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function stripHtml(html: string): string {
|
||||
return html.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
export function buildNoteContextPack(note: NoteDetail, opts: ContextPackOptions): string {
|
||||
const exportedAt = opts.exportedAt ?? new Date().toISOString();
|
||||
const lines: string[] = [buildFrontmatter(opts.workspaceId, exportedAt)];
|
||||
lines.push(`# ${note.title}`, "");
|
||||
if (note.tags.length) {
|
||||
lines.push(`Tags: ${note.tags.map((t) => `\`${t}\``).join(", ")}`, "");
|
||||
}
|
||||
lines.push("## Body", "", stripHtml(note.body) || "(empty)", "");
|
||||
if (note.linkedNotes.length) {
|
||||
lines.push("## Linked notes", "");
|
||||
for (const ln of note.linkedNotes) {
|
||||
lines.push(`- [${ln.title}](/notes/${ln.id}) — _${ln.relationship}_`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
if (opts.includeTasks !== false && note.tasks.length) {
|
||||
lines.push("## Tasks", "");
|
||||
for (const t of note.tasks) {
|
||||
lines.push(`- [${t.status}] ${t.title} (_${t.source}_)`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function buildWorkspaceContextPackMarkdown(params: {
|
||||
workspaceId: string;
|
||||
workspaceName: string;
|
||||
notes: Array<{ id: string; title: string; body: string; tags: string[] }>;
|
||||
exportedAt?: string;
|
||||
}): string {
|
||||
const exportedAt = params.exportedAt ?? new Date().toISOString();
|
||||
const lines: string[] = [
|
||||
buildFrontmatter(params.workspaceId, exportedAt),
|
||||
`# Workspace: ${params.workspaceName}`,
|
||||
"",
|
||||
`_Packed notes: ${params.notes.length}_`,
|
||||
"",
|
||||
];
|
||||
for (const n of params.notes) {
|
||||
lines.push(`## ${n.title}`, `id: \`${n.id}\``, "");
|
||||
if (n.tags.length) lines.push(`Tags: ${n.tags.join(", ")}`, "");
|
||||
lines.push(stripHtml(n.body).slice(0, 8000) + (n.body.length > 8000 ? "\n\n…" : ""), "", "---", "");
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
28
web/src/lib/copilot-client.ts
Normal file
28
web/src/lib/copilot-client.ts
Normal file
@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { createNotesApiClient } from "@/lib/api-helpers";
|
||||
|
||||
export type CopilotAction = "shorten" | "expand" | "bulletize" | "grammar";
|
||||
|
||||
export async function copilotTransform(
|
||||
noteId: string,
|
||||
workspaceId: string,
|
||||
action: CopilotAction,
|
||||
text: string,
|
||||
): Promise<string> {
|
||||
const api = createNotesApiClient();
|
||||
const res = await api.fetch<{ text: string }>(`/notes/${encodeURIComponent(noteId)}/copilot`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ workspaceId, action, text }),
|
||||
});
|
||||
return res.text;
|
||||
}
|
||||
|
||||
export async function suggestNoteTitle(noteId: string, workspaceId: string): Promise<string> {
|
||||
const api = createNotesApiClient();
|
||||
const res = await api.fetch<{ title: string }>(`/notes/${encodeURIComponent(noteId)}/suggest-title`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ workspaceId }),
|
||||
});
|
||||
return res.title;
|
||||
}
|
||||
27
web/src/lib/note-templates.ts
Normal file
27
web/src/lib/note-templates.ts
Normal file
@ -0,0 +1,27 @@
|
||||
export interface NoteTemplate {
|
||||
id: string;
|
||||
label: string;
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export const NOTE_TEMPLATES: NoteTemplate[] = [
|
||||
{
|
||||
id: "meeting",
|
||||
label: "Meeting notes",
|
||||
title: "Meeting — ",
|
||||
body: "<h2>Attendees</h2><p></p><h2>Agenda</h2><ul><li></li></ul><h2>Decisions</h2><ul><li></li></ul><h2>Actions</h2><ul><li></li></ul>",
|
||||
},
|
||||
{
|
||||
id: "decision",
|
||||
label: "Decision log",
|
||||
title: "Decision: ",
|
||||
body: "<h2>Context</h2><p></p><h2>Options</h2><ul><li></li></ul><h2>Decision</h2><p></p><h2>Consequences</h2><p></p>",
|
||||
},
|
||||
{
|
||||
id: "spec",
|
||||
label: "Light spec",
|
||||
title: "Spec: ",
|
||||
body: "<h2>Problem</h2><p></p><h2>Proposal</h2><p></p><h2>Scope</h2><ul><li></li></ul><h2>Risks</h2><ul><li></li></ul><h2>Open questions</h2><ul><li></li></ul>",
|
||||
},
|
||||
];
|
||||
@ -1,7 +1,6 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
const extractSuggestedTasksMock = vi.fn();
|
||||
|
||||
vi.mock("@bytelyst/api-client", () => ({
|
||||
createApiClient: () => ({
|
||||
@ -9,19 +8,14 @@ vi.mock("@bytelyst/api-client", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/extraction-client", () => ({
|
||||
extractSuggestedTasks: (...args: unknown[]) => extractSuggestedTasksMock(...args),
|
||||
}));
|
||||
|
||||
import { getNoteDetail } from "@/lib/notes-client";
|
||||
|
||||
describe("getNoteDetail", () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
extractSuggestedTasksMock.mockReset();
|
||||
});
|
||||
|
||||
it("merges backend tasks with extracted suggestions, preserves artifact blob metadata, and normalizes review state", async () => {
|
||||
it("returns persisted tasks only, preserves artifact blob metadata, and normalizes review state", async () => {
|
||||
const noteItem = {
|
||||
id: "note-1",
|
||||
workspaceId: "workspace-1",
|
||||
@ -102,27 +96,9 @@ describe("getNoteDetail", () => {
|
||||
// 7. GET /note-relationships (sequential after parallel batch)
|
||||
fetchMock.mockResolvedValueOnce({ items: [] });
|
||||
|
||||
extractSuggestedTasksMock.mockResolvedValue([
|
||||
{
|
||||
id: "extract-review-0",
|
||||
title: "Review approval UX cut line",
|
||||
status: "todo",
|
||||
source: "agent",
|
||||
},
|
||||
{
|
||||
id: "extract-test-1",
|
||||
title: "Sarah agreed to handle the testing",
|
||||
status: "todo",
|
||||
source: "agent",
|
||||
},
|
||||
]);
|
||||
|
||||
const note = await getNoteDetail("note-1");
|
||||
|
||||
expect(note).not.toBeNull();
|
||||
expect(extractSuggestedTasksMock).toHaveBeenCalledWith(
|
||||
"Sarah agreed to handle the testing by Friday."
|
||||
);
|
||||
expect(note?.metadata.reviewState).toBe("none");
|
||||
expect(note?.tasks).toEqual([
|
||||
{
|
||||
@ -131,12 +107,6 @@ describe("getNoteDetail", () => {
|
||||
status: "todo",
|
||||
source: "manual",
|
||||
},
|
||||
{
|
||||
id: "extract-test-1",
|
||||
title: "Sarah agreed to handle the testing",
|
||||
status: "todo",
|
||||
source: "agent",
|
||||
},
|
||||
]);
|
||||
expect(note?.artifacts).toEqual([
|
||||
{
|
||||
@ -159,6 +129,5 @@ describe("getNoteDetail", () => {
|
||||
const note = await getNoteDetail("missing-note");
|
||||
|
||||
expect(note).toBeNull();
|
||||
expect(extractSuggestedTasksMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { extractSuggestedTasks } from "@/lib/extraction-client";
|
||||
import { createNotesApiClient } from "@/lib/api-helpers";
|
||||
import type {
|
||||
AgentTimelineItem,
|
||||
@ -177,6 +176,75 @@ export async function searchNoteSummaries(query: string): Promise<NoteSummary[]>
|
||||
return response.items.map(toNoteSummary);
|
||||
}
|
||||
|
||||
export interface SearchRankedHit {
|
||||
noteId: string;
|
||||
workspaceId: string;
|
||||
title: string;
|
||||
score: number;
|
||||
matchKind: string;
|
||||
snippet: string;
|
||||
}
|
||||
|
||||
export async function searchNotesRanked(
|
||||
q: string,
|
||||
mode: "lexical" | "hybrid",
|
||||
options?: { limit?: number; offset?: number; workspaceId?: string },
|
||||
): Promise<{ items: SearchRankedHit[]; mode: string; total: number }> {
|
||||
const api = createNotesApiClient();
|
||||
return api.fetch("/notes/search", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
q: q.trim(),
|
||||
mode,
|
||||
workspaceId: options?.workspaceId,
|
||||
limit: options?.limit ?? 50,
|
||||
offset: options?.offset ?? 0,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createNoteShare(noteId: string, workspaceId: string): Promise<{ shareToken: string; path: string }> {
|
||||
const api = createNotesApiClient();
|
||||
return api.fetch(`/notes/${encodeURIComponent(noteId)}/share`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ workspaceId }),
|
||||
});
|
||||
}
|
||||
|
||||
export interface NoteVersionRow {
|
||||
id: string;
|
||||
noteId: string;
|
||||
title: string;
|
||||
body: string;
|
||||
savedAt: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export async function listNoteVersions(
|
||||
noteId: string,
|
||||
workspaceId: string,
|
||||
): Promise<{ items: NoteVersionRow[]; total: number }> {
|
||||
const api = createNotesApiClient();
|
||||
const qs = new URLSearchParams({ workspaceId, limit: "30", offset: "0" });
|
||||
return api.fetch(`/notes/${encodeURIComponent(noteId)}/versions?${qs.toString()}`);
|
||||
}
|
||||
|
||||
export async function seedOnboardingWorkspace(): Promise<{ workspaceId: string; noteIds: string[] }> {
|
||||
const api = createNotesApiClient();
|
||||
return api.fetch("/workspaces/onboarding-seed", { method: "POST", body: JSON.stringify({}) });
|
||||
}
|
||||
|
||||
export async function chatOverWorkspace(workspaceId: string, message: string): Promise<{
|
||||
answer: string;
|
||||
citations: Array<{ noteId: string; title: string; snippet: string; workspaceId: string }>;
|
||||
}> {
|
||||
const api = createNotesApiClient();
|
||||
return api.fetch("/notes/chat", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ workspaceId, message }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function listNotesForWorkspace(workspaceId: string): Promise<NoteSummary[]> {
|
||||
const api = createNotesApiClient();
|
||||
const response = await api.fetch<NoteListResponse>(`/notes?workspaceId=${encodeURIComponent(workspaceId)}`);
|
||||
@ -286,12 +354,7 @@ export async function getNoteDetail(noteId: string, knownWorkspaceId?: string):
|
||||
relationshipResponse = { items: [] };
|
||||
}
|
||||
|
||||
const extractedTasks = await extractSuggestedTasks(note.body).catch(() => []);
|
||||
const existingTaskTitles = new Set(taskResponse.items.map((task) => task.title.trim().toLowerCase()));
|
||||
const tasks = [
|
||||
...taskResponse.items.map(toNoteTask),
|
||||
...extractedTasks.filter((task) => !existingTaskTitles.has(task.title.trim().toLowerCase())),
|
||||
];
|
||||
const tasks = taskResponse.items.map(toNoteTask);
|
||||
const artifacts = artifactResponse.items.map(toArtifactSummary);
|
||||
const timeline = actionResponse.items
|
||||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
|
||||
|
||||
@ -8,6 +8,12 @@ export const PLATFORM_SERVICE_ORIGIN =
|
||||
process.env.NEXT_PUBLIC_PLATFORM_SERVICE_ORIGIN ??
|
||||
PLATFORM_SERVICE_URL.replace(/\/api\/?$/, "");
|
||||
export const NOTES_API_URL = process.env.NEXT_PUBLIC_NOTES_API_URL ?? "http://localhost:4016/api";
|
||||
/** Safe on SSR — call from client when copying share links. */
|
||||
export function getWebAppOrigin(): string {
|
||||
if (typeof window !== "undefined") return window.location.origin;
|
||||
return process.env.NEXT_PUBLIC_WEB_APP_ORIGIN ?? "";
|
||||
}
|
||||
export const MCP_SERVER_URL = process.env.NEXT_PUBLIC_MCP_SERVER_URL ?? "http://localhost:4050/mcp";
|
||||
export const EXTRACTION_SERVICE_URL = process.env.NEXT_PUBLIC_EXTRACTION_SERVICE_URL ?? "http://localhost:4005";
|
||||
export const DIAGNOSTICS_URL = process.env.NEXT_PUBLIC_DIAGNOSTICS_URL ?? PLATFORM_SERVICE_ORIGIN;
|
||||
export const TELEMETRY_TRANSPORT =
|
||||
|
||||
Loading…
Reference in New Issue
Block a user