first commit of v.02 application

This commit is contained in:
KS Jannette
2026-05-07 23:20:30 -04:00
commit a6b0a95dfc
98 changed files with 21028 additions and 0 deletions

9
client/src/api/client.js Normal file
View File

@@ -0,0 +1,9 @@
'use strict';
export async function handleResponse(res) {
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Request failed: ${res.status}`);
}
return res.json();
}

View File

@@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest';
import { handleResponse } from './client.js';
function fakeResponse(status, body, ok = status >= 200 && status < 300) {
return {
ok,
status,
json: () => Promise.resolve(body),
};
}
describe('handleResponse', () => {
it('returns parsed JSON on success', async () => {
const data = { id: 1, name: 'test' };
const result = await handleResponse(fakeResponse(200, data));
expect(result).toEqual(data);
});
it('throws with body.error when present', async () => {
const res = fakeResponse(400, { error: 'Bad input' }, false);
await expect(handleResponse(res)).rejects.toThrow('Bad input');
});
it('throws with status code when body has no error field', async () => {
const res = fakeResponse(500, {}, false);
await expect(handleResponse(res)).rejects.toThrow('Request failed: 500');
});
it('throws with status code when body JSON parsing fails', async () => {
const res = {
ok: false,
status: 502,
json: () => Promise.reject(new Error('parse error')),
};
await expect(handleResponse(res)).rejects.toThrow('Request failed: 502');
});
it('returns empty object body on success', async () => {
const result = await handleResponse(fakeResponse(200, {}));
expect(result).toEqual({});
});
it('returns array body on success', async () => {
const data = [1, 2, 3];
const result = await handleResponse(fakeResponse(200, data));
expect(result).toEqual([1, 2, 3]);
});
});

View File

@@ -0,0 +1,14 @@
'use strict';
import { handleResponse } from './client.js';
const BASE = '/api/documents';
export async function generateDocument(notebookId, type) {
const res = await fetch(`${BASE}/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notebookId, type }),
});
return handleResponse(res);
}

View File

@@ -0,0 +1,24 @@
'use strict';
import { handleResponse } from './client.js';
const BASE = '/api/notebooks';
export async function listNotebooks() {
const res = await fetch(BASE);
return handleResponse(res);
}
export async function createNotebook(name) {
const res = await fetch(BASE, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
return handleResponse(res);
}
export async function deleteNotebook(id) {
const res = await fetch(`${BASE}/${id}`, { method: 'DELETE' });
return handleResponse(res);
}

View File

@@ -0,0 +1,68 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { listNotebooks, createNotebook, deleteNotebook } from './notebooks.js';
const okResponse = (body) => ({
ok: true,
status: 200,
json: () => Promise.resolve(body),
});
describe('notebooks API', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn());
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('listNotebooks', () => {
it('fetches GET /api/notebooks', async () => {
const data = [{ id: '1', name: 'NB' }];
fetch.mockResolvedValue(okResponse(data));
const result = await listNotebooks();
expect(fetch).toHaveBeenCalledWith('/api/notebooks');
expect(result).toEqual(data);
});
});
describe('createNotebook', () => {
it('sends POST with name in JSON body', async () => {
const nb = { id: '2', name: 'New' };
fetch.mockResolvedValue(okResponse(nb));
const result = await createNotebook('New');
expect(fetch).toHaveBeenCalledWith('/api/notebooks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'New' }),
});
expect(result).toEqual(nb);
});
});
describe('deleteNotebook', () => {
it('sends DELETE to /api/notebooks/:id', async () => {
fetch.mockResolvedValue(okResponse({}));
await deleteNotebook('abc-123');
expect(fetch).toHaveBeenCalledWith('/api/notebooks/abc-123', {
method: 'DELETE',
});
});
});
it('throws on non-ok response', async () => {
fetch.mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.resolve({ error: 'Server down' }),
});
await expect(listNotebooks()).rejects.toThrow('Server down');
});
});

36
client/src/api/query.js Normal file
View File

@@ -0,0 +1,36 @@
'use strict';
import { handleResponse } from './client.js';
const BASE = '/api/query';
const CITATION_DETAIL_BASE = '/api/citation-detail';
export async function sendQuery(notebookId, question, onCitationDetails) {
const res = await fetch(BASE, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notebookId, question }),
});
const data = await handleResponse(res);
if (data.citations?.length && onCitationDetails) {
Promise.all(
data.citations.map((c) =>
fetch(CITATION_DETAIL_BASE, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chunkTexts: c.chunkTexts,
sourceName: c.name,
answer: data.answer,
citationIndex: c.sourceIndex,
}),
})
.then((r) => handleResponse(r))
.then((detail) => ({ sourceIndex: c.sourceIndex, ...detail }))
)
).then(onCitationDetails);
}
return data;
}

View File

@@ -0,0 +1,139 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { sendQuery } from './query.js';
const okResponse = (body) => ({
ok: true,
status: 200,
json: () => Promise.resolve(body),
});
describe('sendQuery', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn());
});
afterEach(() => {
vi.restoreAllMocks();
});
it('sends POST to /api/query with notebookId and question', async () => {
const data = { answer: 'The answer', citations: [] };
fetch.mockResolvedValue(okResponse(data));
const result = await sendQuery('nb-1', 'What is AI?');
expect(fetch).toHaveBeenCalledWith('/api/query', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notebookId: 'nb-1', question: 'What is AI?' }),
});
expect(result).toEqual(data);
});
it('returns data without calling onCitationDetails when no citations', async () => {
const data = { answer: 'Answer', citations: [] };
fetch.mockResolvedValue(okResponse(data));
const onCitationDetails = vi.fn();
await sendQuery('nb-1', 'Q?', onCitationDetails);
expect(onCitationDetails).not.toHaveBeenCalled();
});
it('returns data without calling onCitationDetails when citations is null', async () => {
const data = { answer: 'Answer', citations: null };
fetch.mockResolvedValue(okResponse(data));
const onCitationDetails = vi.fn();
await sendQuery('nb-1', 'Q?', onCitationDetails);
expect(onCitationDetails).not.toHaveBeenCalled();
});
it('fetches citation details and calls onCitationDetails', async () => {
const queryData = {
answer: 'The answer [1]',
citations: [
{ sourceIndex: 1, name: 'doc.pdf', chunkTexts: ['chunk A'] },
{ sourceIndex: 2, name: 'doc2.pdf', chunkTexts: ['chunk B'] },
],
};
const detailA = { citedSentence: 'A', topicSummary: 'T1' };
const detailB = { citedSentence: 'B', topicSummary: 'T2' };
fetch
.mockResolvedValueOnce(okResponse(queryData))
.mockResolvedValueOnce(okResponse(detailA))
.mockResolvedValueOnce(okResponse(detailB));
const onCitationDetails = vi.fn();
const result = await sendQuery('nb-1', 'Q?', onCitationDetails);
expect(result).toEqual(queryData);
// Citation detail fetches happen async — wait for the promise
await vi.waitFor(() => {
expect(onCitationDetails).toHaveBeenCalledOnce();
});
const details = onCitationDetails.mock.calls[0][0];
expect(details).toHaveLength(2);
expect(details[0]).toEqual({ sourceIndex: 1, ...detailA });
expect(details[1]).toEqual({ sourceIndex: 2, ...detailB });
});
it('sends correct body for each citation detail request', async () => {
const queryData = {
answer: 'Answer text',
citations: [
{ sourceIndex: 1, name: 'src.pdf', chunkTexts: ['c1', 'c2'] },
],
};
const detail = { citedSentence: 'S' };
fetch
.mockResolvedValueOnce(okResponse(queryData))
.mockResolvedValueOnce(okResponse(detail));
const onCitationDetails = vi.fn();
await sendQuery('nb-1', 'Q?', onCitationDetails);
await vi.waitFor(() => {
expect(fetch).toHaveBeenCalledTimes(2);
});
expect(fetch).toHaveBeenCalledWith('/api/citation-detail', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chunkTexts: ['c1', 'c2'],
sourceName: 'src.pdf',
answer: 'Answer text',
citationIndex: 1,
}),
});
});
it('does not fetch citation details when no callback provided', async () => {
const queryData = {
answer: 'Answer',
citations: [{ sourceIndex: 1, name: 'x', chunkTexts: ['c'] }],
};
fetch.mockResolvedValue(okResponse(queryData));
await sendQuery('nb-1', 'Q?');
// Only 1 fetch call (the query itself), no citation detail fetches
expect(fetch).toHaveBeenCalledTimes(1);
});
it('throws on non-ok response from query endpoint', async () => {
fetch.mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.resolve({ error: 'Internal error' }),
});
await expect(sendQuery('nb-1', 'Q?')).rejects.toThrow('Internal error');
});
});

30
client/src/api/sources.js Normal file
View File

@@ -0,0 +1,30 @@
'use strict';
import { handleResponse } from './client.js';
const BASE = '/api/sources';
export async function listSources(notebookId) {
const res = await fetch(`${BASE}?notebookId=${notebookId}`);
return handleResponse(res);
}
export async function uploadSource(notebookId, file) {
const formData = new FormData();
formData.append('file', file);
formData.append('notebookId', notebookId);
const res = await fetch(BASE, {
method: 'POST',
body: formData,
});
return handleResponse(res);
}
export async function addUrlSource(notebookId, url) {
const res = await fetch(`${BASE}/url`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notebookId, url }),
});
return handleResponse(res);
}

View File

@@ -0,0 +1,76 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { listSources, uploadSource, addUrlSource } from './sources.js';
const okResponse = (body) => ({
ok: true,
status: 200,
json: () => Promise.resolve(body),
});
describe('sources API', () => {
beforeEach(() => {
vi.stubGlobal('fetch', vi.fn());
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('listSources', () => {
it('fetches GET /api/sources with notebookId query param', async () => {
const data = [{ id: 's1', name: 'file.pdf' }];
fetch.mockResolvedValue(okResponse(data));
const result = await listSources('nb-42');
expect(fetch).toHaveBeenCalledWith('/api/sources?notebookId=nb-42');
expect(result).toEqual(data);
});
});
describe('uploadSource', () => {
it('sends POST with FormData containing file and notebookId', async () => {
const source = { id: 's2', name: 'doc.pdf' };
fetch.mockResolvedValue(okResponse(source));
const fakeFile = new File(['content'], 'doc.pdf', { type: 'application/pdf' });
const result = await uploadSource('nb-1', fakeFile);
expect(fetch).toHaveBeenCalledWith('/api/sources', {
method: 'POST',
body: expect.any(FormData),
});
const formData = fetch.mock.calls[0][1].body;
expect(formData.get('notebookId')).toBe('nb-1');
expect(formData.get('file')).toBeInstanceOf(File);
expect(result).toEqual(source);
});
});
describe('addUrlSource', () => {
it('sends POST to /api/sources/url with JSON body', async () => {
const source = { id: 's3', name: 'example.com' };
fetch.mockResolvedValue(okResponse(source));
const result = await addUrlSource('nb-5', 'https://example.com');
expect(fetch).toHaveBeenCalledWith('/api/sources/url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ notebookId: 'nb-5', url: 'https://example.com' }),
});
expect(result).toEqual(source);
});
});
it('throws on non-ok response', async () => {
fetch.mockResolvedValue({
ok: false,
status: 400,
json: () => Promise.resolve({ error: 'Bad request' }),
});
await expect(listSources('nb-1')).rejects.toThrow('Bad request');
});
});