first commit of v.02 application
This commit is contained in:
111
client/src/App.jsx
Normal file
111
client/src/App.jsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { useState } from 'react';
|
||||
import NotebookList from './components/NotebookList.jsx';
|
||||
import SourcePanel from './components/SourcePanel.jsx';
|
||||
import DocumentButtons from './components/DocumentButtons.jsx';
|
||||
import ChatPanel from './components/ChatPanel.jsx';
|
||||
import DocumentModal from './components/DocumentModal.jsx';
|
||||
import { useNotebook } from './hooks/useNotebook.js';
|
||||
import { generateDocument } from './api/documents.js';
|
||||
|
||||
function App() {
|
||||
const {
|
||||
notebooks,
|
||||
activeNotebook,
|
||||
selectNotebook,
|
||||
createNotebook,
|
||||
deleteNotebook,
|
||||
} = useNotebook();
|
||||
|
||||
const [hoverState, setHoverState] = useState(null);
|
||||
const [sourceCount, setSourceCount] = useState(0);
|
||||
const [generatingType, setGeneratingType] = useState(null);
|
||||
const [docModal, setDocModal] = useState({ open: false, type: null, document: null, loading: false });
|
||||
const [chatReady, setChatReady] = useState(false);
|
||||
|
||||
const handleSelectNotebook = (id) => {
|
||||
setChatReady(false);
|
||||
selectNotebook(id);
|
||||
};
|
||||
|
||||
const handleSourceHover = (val) => {
|
||||
if (val === null) {
|
||||
setHoverState(null);
|
||||
} else if (typeof val === 'number') {
|
||||
setHoverState({ instanceId: `sidebar-${val}`, docIndex: val });
|
||||
} else {
|
||||
setHoverState(val);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDocumentRequest = async (type) => {
|
||||
setGeneratingType(type);
|
||||
setDocModal({ open: true, type, document: null, loading: true });
|
||||
|
||||
try {
|
||||
const res = await generateDocument(activeNotebook.id, type);
|
||||
setDocModal({ open: true, type, document: res.document, loading: false });
|
||||
} catch (err) {
|
||||
console.error('document generation failed', err);
|
||||
setDocModal({ open: false, type: null, document: null, loading: false });
|
||||
} finally {
|
||||
setGeneratingType(null);
|
||||
}
|
||||
};
|
||||
|
||||
const hoveredDocIndex = hoverState?.docIndex ?? null;
|
||||
const hoveredInstanceId = hoverState?.instanceId ?? null;
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<aside className="sidebar">
|
||||
<NotebookList
|
||||
notebooks={notebooks}
|
||||
activeId={activeNotebook?.id}
|
||||
onSelect={handleSelectNotebook}
|
||||
onCreate={createNotebook}
|
||||
onDelete={deleteNotebook}
|
||||
/>
|
||||
{activeNotebook && (
|
||||
<>
|
||||
<SourcePanel
|
||||
notebookId={activeNotebook.id}
|
||||
hoveredSourceIndex={hoveredDocIndex}
|
||||
onSourceHover={handleSourceHover}
|
||||
onSourcesChange={setSourceCount}
|
||||
>
|
||||
{chatReady && (
|
||||
<DocumentButtons
|
||||
sourceCount={sourceCount}
|
||||
onRequest={handleDocumentRequest}
|
||||
generatingType={generatingType}
|
||||
/>
|
||||
)}
|
||||
</SourcePanel>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
<main className="main">
|
||||
{activeNotebook ? (
|
||||
<ChatPanel
|
||||
notebookId={activeNotebook.id}
|
||||
hoveredSource={hoveredInstanceId}
|
||||
hoveredDocIndex={hoveredDocIndex}
|
||||
onSourceHover={handleSourceHover}
|
||||
onFirstResponse={() => setChatReady(true)}
|
||||
/>
|
||||
) : (
|
||||
<p>Select or create a notebook to get started.</p>
|
||||
)}
|
||||
</main>
|
||||
<DocumentModal
|
||||
open={docModal.open}
|
||||
onClose={() => setDocModal({ open: false, type: null, document: null, loading: false })}
|
||||
type={docModal.type}
|
||||
document={docModal.document}
|
||||
loading={docModal.loading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
9
client/src/api/client.js
Normal file
9
client/src/api/client.js
Normal 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();
|
||||
}
|
||||
48
client/src/api/client.test.js
Normal file
48
client/src/api/client.test.js
Normal 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]);
|
||||
});
|
||||
});
|
||||
14
client/src/api/documents.js
Normal file
14
client/src/api/documents.js
Normal 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);
|
||||
}
|
||||
24
client/src/api/notebooks.js
Normal file
24
client/src/api/notebooks.js
Normal 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);
|
||||
}
|
||||
68
client/src/api/notebooks.test.js
Normal file
68
client/src/api/notebooks.test.js
Normal 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
36
client/src/api/query.js
Normal 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;
|
||||
}
|
||||
139
client/src/api/query.test.js
Normal file
139
client/src/api/query.test.js
Normal 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
30
client/src/api/sources.js
Normal 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);
|
||||
}
|
||||
76
client/src/api/sources.test.js
Normal file
76
client/src/api/sources.test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
69
client/src/components/ChatMessage.jsx
Normal file
69
client/src/components/ChatMessage.jsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import GroundednessScore from './GroundednessScore.jsx';
|
||||
import FollowUpQuestions from './FollowUpQuestions.jsx';
|
||||
|
||||
function renderAnswerWithCitations(answer, citations, hoveredSource, hoveredDocIndex, onSourceHover, onCitationClick) {
|
||||
if (!citations || citations.length === 0) {
|
||||
return <span className="answer-text">{answer}</span>;
|
||||
}
|
||||
|
||||
const parts = answer.split(/(\[\d+\])/g);
|
||||
let citationCounter = 0;
|
||||
const sidebarIsSource = hoveredSource?.startsWith('sidebar-');
|
||||
return (
|
||||
<span className="answer-text">
|
||||
{parts.map((part, i) => {
|
||||
const match = part.match(/^\[(\d+)\]$/);
|
||||
if (match) {
|
||||
const docIndex = parseInt(match[1], 10);
|
||||
const instanceId = `c-${citationCounter++}`;
|
||||
const isHighlighted =
|
||||
hoveredSource === instanceId ||
|
||||
(sidebarIsSource && hoveredDocIndex === docIndex);
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
className={`citation-marker${isHighlighted ? ' citation-highlighted' : ''}`}
|
||||
title={`Source ${match[1]} — click for details`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Citation source ${match[1]}`}
|
||||
onMouseEnter={() => onSourceHover({ instanceId, docIndex })}
|
||||
onMouseLeave={() => onSourceHover(null)}
|
||||
onClick={() => onCitationClick?.(docIndex)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onCitationClick?.(docIndex);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{match[1]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return part;
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatMessage({ message, onFollowUp, hoveredSource, hoveredDocIndex, onSourceHover, onCitationClick }) {
|
||||
if (message.role === 'user') {
|
||||
return <div className="chat-message user">{message.text}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chat-message assistant">
|
||||
{renderAnswerWithCitations(message.answer, message.citations, hoveredSource, hoveredDocIndex, onSourceHover, onCitationClick)}
|
||||
<div className="message-meta">
|
||||
<GroundednessScore score={message.groundednessScore} />
|
||||
</div>
|
||||
<FollowUpQuestions
|
||||
questions={message.followUpQuestions}
|
||||
onSelect={onFollowUp}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChatMessage;
|
||||
165
client/src/components/ChatMessage.test.jsx
Normal file
165
client/src/components/ChatMessage.test.jsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import ChatMessage from './ChatMessage.jsx';
|
||||
|
||||
describe('ChatMessage', () => {
|
||||
describe('user messages', () => {
|
||||
it('renders user text in a user-styled div', () => {
|
||||
const msg = { role: 'user', text: 'Hello there' };
|
||||
const { container } = render(<ChatMessage message={msg} />);
|
||||
const el = container.querySelector('.chat-message.user');
|
||||
expect(el).toHaveTextContent('Hello there');
|
||||
});
|
||||
|
||||
it('does not render groundedness or follow-ups for user messages', () => {
|
||||
const msg = { role: 'user', text: 'Hi' };
|
||||
const { container } = render(<ChatMessage message={msg} />);
|
||||
expect(container.querySelector('.groundedness-score')).toBeNull();
|
||||
expect(container.querySelector('.follow-up-questions')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('assistant messages — plain text (no citations)', () => {
|
||||
it('renders answer text without citation markers', () => {
|
||||
const msg = { role: 'assistant', answer: 'The sky is blue.', citations: [] };
|
||||
render(<ChatMessage message={msg} onFollowUp={() => {}} onSourceHover={() => {}} />);
|
||||
expect(screen.getByText('The sky is blue.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders answer when citations is null', () => {
|
||||
const msg = { role: 'assistant', answer: 'No sources.', citations: null };
|
||||
render(<ChatMessage message={msg} onFollowUp={() => {}} onSourceHover={() => {}} />);
|
||||
expect(screen.getByText('No sources.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('assistant messages — with citations', () => {
|
||||
const baseCitations = [{ sourceIndex: 1 }, { sourceIndex: 2 }];
|
||||
|
||||
it('renders citation numbers as clickable buttons', () => {
|
||||
const msg = {
|
||||
role: 'assistant',
|
||||
answer: 'Fact one [1] and fact two [2].',
|
||||
citations: baseCitations,
|
||||
};
|
||||
render(<ChatMessage message={msg} onFollowUp={() => {}} onSourceHover={() => {}} />);
|
||||
|
||||
const markers = screen.getAllByRole('button');
|
||||
expect(markers).toHaveLength(2);
|
||||
expect(markers[0]).toHaveTextContent('1');
|
||||
expect(markers[1]).toHaveTextContent('2');
|
||||
});
|
||||
|
||||
it('sets aria-label on citation markers', () => {
|
||||
const msg = {
|
||||
role: 'assistant',
|
||||
answer: 'Info [3].',
|
||||
citations: [{ sourceIndex: 3 }],
|
||||
};
|
||||
render(<ChatMessage message={msg} onFollowUp={() => {}} onSourceHover={() => {}} />);
|
||||
expect(screen.getByLabelText('Citation source 3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onCitationClick with docIndex when citation is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCitationClick = vi.fn();
|
||||
const msg = {
|
||||
role: 'assistant',
|
||||
answer: 'See [2] here.',
|
||||
citations: [{ sourceIndex: 1 }, { sourceIndex: 2 }],
|
||||
};
|
||||
render(
|
||||
<ChatMessage
|
||||
message={msg}
|
||||
onFollowUp={() => {}}
|
||||
onSourceHover={() => {}}
|
||||
onCitationClick={onCitationClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByLabelText('Citation source 2'));
|
||||
expect(onCitationClick).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it('calls onSourceHover on mouseEnter/mouseLeave of citation', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSourceHover = vi.fn();
|
||||
const msg = {
|
||||
role: 'assistant',
|
||||
answer: 'Ref [1].',
|
||||
citations: [{ sourceIndex: 1 }],
|
||||
};
|
||||
render(
|
||||
<ChatMessage
|
||||
message={msg}
|
||||
onFollowUp={() => {}}
|
||||
onSourceHover={onSourceHover}
|
||||
/>,
|
||||
);
|
||||
|
||||
const marker = screen.getByLabelText('Citation source 1');
|
||||
await user.hover(marker);
|
||||
expect(onSourceHover).toHaveBeenCalledWith({ instanceId: 'c-0', docIndex: 1 });
|
||||
|
||||
await user.unhover(marker);
|
||||
expect(onSourceHover).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('highlights citation when hoveredSource matches instanceId', () => {
|
||||
const msg = {
|
||||
role: 'assistant',
|
||||
answer: 'Ref [1].',
|
||||
citations: [{ sourceIndex: 1 }],
|
||||
};
|
||||
const { container } = render(
|
||||
<ChatMessage
|
||||
message={msg}
|
||||
hoveredSource="c-0"
|
||||
onFollowUp={() => {}}
|
||||
onSourceHover={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(container.querySelector('.citation-highlighted')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('handles multiple citations in sequence', () => {
|
||||
const msg = {
|
||||
role: 'assistant',
|
||||
answer: '[1][2][3]',
|
||||
citations: [{ sourceIndex: 1 }, { sourceIndex: 2 }, { sourceIndex: 3 }],
|
||||
};
|
||||
render(<ChatMessage message={msg} onFollowUp={() => {}} onSourceHover={() => {}} />);
|
||||
const markers = screen.getAllByRole('button');
|
||||
expect(markers).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groundedness and follow-ups', () => {
|
||||
it('renders GroundednessScore when present', () => {
|
||||
const msg = {
|
||||
role: 'assistant',
|
||||
answer: 'Answer',
|
||||
citations: [],
|
||||
groundednessScore: 0.85,
|
||||
};
|
||||
render(<ChatMessage message={msg} onFollowUp={() => {}} onSourceHover={() => {}} />);
|
||||
expect(screen.getByText(/Grounded: 85%/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders follow-up questions and forwards onFollowUp', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onFollowUp = vi.fn();
|
||||
const msg = {
|
||||
role: 'assistant',
|
||||
answer: 'Answer',
|
||||
citations: [],
|
||||
followUpQuestions: ['Tell me more', 'Why?'],
|
||||
};
|
||||
render(<ChatMessage message={msg} onFollowUp={onFollowUp} onSourceHover={() => {}} />);
|
||||
|
||||
await user.click(screen.getByText('Why?'));
|
||||
expect(onFollowUp).toHaveBeenCalledWith('Why?');
|
||||
});
|
||||
});
|
||||
});
|
||||
150
client/src/components/ChatPanel.jsx
Normal file
150
client/src/components/ChatPanel.jsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { sendQuery } from '../api/query.js';
|
||||
import ChatMessage from './ChatMessage.jsx';
|
||||
import CitationDetailModal from './CitationDetailModal.jsx';
|
||||
|
||||
function ChatPanel({ notebookId, hoveredSource, hoveredDocIndex, onSourceHover, onFirstResponse }) {
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [citationDetails, setCitationDetails] = useState({});
|
||||
const [activeCitation, setActiveCitation] = useState(null);
|
||||
const bottomRef = useRef(null);
|
||||
const firstResponseFired = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMessages([]);
|
||||
setInput('');
|
||||
setCitationDetails({});
|
||||
setActiveCitation(null);
|
||||
firstResponseFired.current = false;
|
||||
}, [notebookId]);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
const submitQuery = async (question) => {
|
||||
if (!question.trim() || loading) return;
|
||||
|
||||
const userMsg = { role: 'user', text: question, msgId: crypto.randomUUID() };
|
||||
setMessages((prev) => [...prev, userMsg]);
|
||||
setInput('');
|
||||
setLoading(true);
|
||||
|
||||
const msgId = crypto.randomUUID();
|
||||
|
||||
try {
|
||||
const res = await sendQuery(notebookId, question, (details) => {
|
||||
const detailMap = {};
|
||||
for (const d of details) {
|
||||
detailMap[d.sourceIndex] = d;
|
||||
}
|
||||
setCitationDetails((prev) => ({ ...prev, [msgId]: detailMap }));
|
||||
});
|
||||
|
||||
const assistantMsg = {
|
||||
role: 'assistant',
|
||||
answer: res.answer,
|
||||
citations: res.citations || [],
|
||||
groundednessScore: res.groundednessScore,
|
||||
followUpQuestions: res.followUpQuestions || [],
|
||||
msgId,
|
||||
};
|
||||
setMessages((prev) => [...prev, assistantMsg]);
|
||||
if (!firstResponseFired.current) {
|
||||
firstResponseFired.current = true;
|
||||
onFirstResponse?.();
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMsg = {
|
||||
role: 'assistant',
|
||||
msgId,
|
||||
answer: 'Something went wrong. Please try again.',
|
||||
citations: [],
|
||||
groundednessScore: null,
|
||||
followUpQuestions: [],
|
||||
};
|
||||
setMessages((prev) => [...prev, errorMsg]);
|
||||
console.error('query failed', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
submitQuery(input);
|
||||
};
|
||||
|
||||
const handleFollowUp = (question) => {
|
||||
submitQuery(question);
|
||||
};
|
||||
|
||||
const handleCitationClick = (msgId, docIndex) => {
|
||||
const citation = messages.find(
|
||||
(m) => m.msgId === msgId
|
||||
)?.citations?.find((c) => c.sourceIndex === docIndex);
|
||||
|
||||
setActiveCitation({
|
||||
msgId,
|
||||
docIndex,
|
||||
sourceName: citation?.name || 'Unknown',
|
||||
});
|
||||
};
|
||||
|
||||
const activeDetail =
|
||||
activeCitation
|
||||
? citationDetails[activeCitation.msgId]?.[activeCitation.docIndex] ?? null
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="chat-panel">
|
||||
<div className="chat-messages">
|
||||
{messages.length === 0 && !loading && (
|
||||
<div className="chat-empty">
|
||||
Upload sources and ask a question to get started.
|
||||
</div>
|
||||
)}
|
||||
{messages.map((msg) => (
|
||||
<ChatMessage
|
||||
key={msg.msgId}
|
||||
message={msg}
|
||||
onFollowUp={handleFollowUp}
|
||||
hoveredSource={hoveredSource}
|
||||
hoveredDocIndex={hoveredDocIndex}
|
||||
onSourceHover={onSourceHover}
|
||||
onCitationClick={(docIndex) => handleCitationClick(msg.msgId, docIndex)}
|
||||
/>
|
||||
))}
|
||||
{loading && (
|
||||
<div className="chat-message assistant loading">
|
||||
Thinking<span className="loading-dots" />
|
||||
</div>
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
<form className="chat-input" onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Ask a question about your sources..."
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
disabled={loading}
|
||||
/>
|
||||
<button type="submit" disabled={loading || !input.trim()}>
|
||||
Send
|
||||
</button>
|
||||
</form>
|
||||
<CitationDetailModal
|
||||
open={!!activeCitation}
|
||||
onClose={() => setActiveCitation(null)}
|
||||
detail={activeDetail}
|
||||
sourceName={activeCitation?.sourceName}
|
||||
citationIndex={activeCitation?.docIndex}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChatPanel;
|
||||
53
client/src/components/CitationDetailModal.jsx
Normal file
53
client/src/components/CitationDetailModal.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import Modal from './Modal.jsx';
|
||||
|
||||
function CitationDetailModal({ open, onClose, detail, sourceName, citationIndex }) {
|
||||
const loading = !detail;
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={`Source ${citationIndex} — ${sourceName || 'Unknown'}`} width="680px">
|
||||
{loading ? (
|
||||
<p className="citation-detail-loading">Loading deeper insight<span className="loading-dots" /></p>
|
||||
) : (
|
||||
<div className="citation-detail-content">
|
||||
<section className="citation-detail-section">
|
||||
<h4 className="citation-detail-label">Cited Passage</h4>
|
||||
<blockquote className="citation-detail-quote">{detail.citedSentence}</blockquote>
|
||||
</section>
|
||||
|
||||
<section className="citation-detail-section">
|
||||
<h4 className="citation-detail-label">Topic Context</h4>
|
||||
<p>{detail.topicSummary}</p>
|
||||
</section>
|
||||
|
||||
<section className="citation-detail-section">
|
||||
<h4 className="citation-detail-label">Additional Insight</h4>
|
||||
<p>{detail.additionalInsight}</p>
|
||||
</section>
|
||||
|
||||
{detail.relevantLinks?.length > 0 && (
|
||||
<section className="citation-detail-section">
|
||||
<h4 className="citation-detail-label">Further Reading</h4>
|
||||
<ul className="citation-detail-links">
|
||||
{detail.relevantLinks.map((link, i) => (
|
||||
<li key={i}>
|
||||
<a href={link.url} target="_blank" rel="noopener noreferrer">
|
||||
{link.title}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="modal-actions">
|
||||
<button className="modal-btn modal-btn-cancel" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default CitationDetailModal;
|
||||
107
client/src/components/CitationDetailModal.test.jsx
Normal file
107
client/src/components/CitationDetailModal.test.jsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import CitationDetailModal from './CitationDetailModal.jsx';
|
||||
|
||||
const detail = {
|
||||
citedSentence: 'The earth orbits the sun.',
|
||||
topicSummary: 'Astronomy basics covering planetary motion.',
|
||||
additionalInsight: 'This was first proven by Copernicus.',
|
||||
relevantLinks: [
|
||||
{ title: 'Wikipedia: Heliocentrism', url: 'https://en.wikipedia.org/wiki/Heliocentrism' },
|
||||
{ title: 'NASA Orbits', url: 'https://nasa.gov/orbits' },
|
||||
],
|
||||
};
|
||||
|
||||
describe('CitationDetailModal', () => {
|
||||
it('renders nothing when open is false', () => {
|
||||
const { container } = render(
|
||||
<CitationDetailModal open={false} onClose={() => {}} detail={detail} sourceName="Doc1" citationIndex={1} />,
|
||||
);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('shows loading state when detail is null', () => {
|
||||
render(
|
||||
<CitationDetailModal open={true} onClose={() => {}} detail={null} sourceName="Doc1" citationIndex={1} />,
|
||||
);
|
||||
expect(screen.getByText(/Loading deeper insight/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the title with source name and index', () => {
|
||||
render(
|
||||
<CitationDetailModal open={true} onClose={() => {}} detail={detail} sourceName="MyDoc.pdf" citationIndex={3} />,
|
||||
);
|
||||
expect(screen.getByText('Source 3 — MyDoc.pdf')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses "Unknown" when sourceName is falsy', () => {
|
||||
render(
|
||||
<CitationDetailModal open={true} onClose={() => {}} detail={detail} sourceName="" citationIndex={1} />,
|
||||
);
|
||||
expect(screen.getByText('Source 1 — Unknown')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders cited passage as blockquote', () => {
|
||||
render(
|
||||
<CitationDetailModal open={true} onClose={() => {}} detail={detail} sourceName="Doc" citationIndex={1} />,
|
||||
);
|
||||
const quote = screen.getByText('The earth orbits the sun.');
|
||||
expect(quote.tagName).toBe('BLOCKQUOTE');
|
||||
});
|
||||
|
||||
it('renders topic context', () => {
|
||||
render(
|
||||
<CitationDetailModal open={true} onClose={() => {}} detail={detail} sourceName="Doc" citationIndex={1} />,
|
||||
);
|
||||
expect(screen.getByText('Astronomy basics covering planetary motion.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders additional insight', () => {
|
||||
render(
|
||||
<CitationDetailModal open={true} onClose={() => {}} detail={detail} sourceName="Doc" citationIndex={1} />,
|
||||
);
|
||||
expect(screen.getByText('This was first proven by Copernicus.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders relevant links', () => {
|
||||
render(
|
||||
<CitationDetailModal open={true} onClose={() => {}} detail={detail} sourceName="Doc" citationIndex={1} />,
|
||||
);
|
||||
const link1 = screen.getByText('Wikipedia: Heliocentrism');
|
||||
expect(link1.closest('a')).toHaveAttribute('href', 'https://en.wikipedia.org/wiki/Heliocentrism');
|
||||
expect(link1.closest('a')).toHaveAttribute('target', '_blank');
|
||||
|
||||
const link2 = screen.getByText('NASA Orbits');
|
||||
expect(link2.closest('a')).toHaveAttribute('href', 'https://nasa.gov/orbits');
|
||||
});
|
||||
|
||||
it('omits Further Reading when relevantLinks is empty', () => {
|
||||
const noLinks = { ...detail, relevantLinks: [] };
|
||||
render(
|
||||
<CitationDetailModal open={true} onClose={() => {}} detail={noLinks} sourceName="Doc" citationIndex={1} />,
|
||||
);
|
||||
expect(screen.queryByText('Further Reading')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders Close button and calls onClose', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<CitationDetailModal open={true} onClose={onClose} detail={detail} sourceName="Doc" citationIndex={1} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('Close'));
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('renders all section labels', () => {
|
||||
render(
|
||||
<CitationDetailModal open={true} onClose={() => {}} detail={detail} sourceName="Doc" citationIndex={1} />,
|
||||
);
|
||||
expect(screen.getByText('Cited Passage')).toBeInTheDocument();
|
||||
expect(screen.getByText('Topic Context')).toBeInTheDocument();
|
||||
expect(screen.getByText('Additional Insight')).toBeInTheDocument();
|
||||
expect(screen.getByText('Further Reading')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
53
client/src/components/CreateNotebookModal.jsx
Normal file
53
client/src/components/CreateNotebookModal.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import Modal from './Modal.jsx';
|
||||
|
||||
function CreateNotebookModal({ open, onConfirm, onCancel }) {
|
||||
const [name, setName] = useState('');
|
||||
const inputRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName('');
|
||||
setTimeout(() => inputRef.current?.focus(), 50);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
const trimmed = name.trim();
|
||||
if (trimmed) onConfirm(trimmed);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onCancel} title="New Notebook">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="modal-input"
|
||||
type="text"
|
||||
placeholder="Enter notebook name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<div className="modal-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="modal-btn modal-btn-cancel"
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="modal-btn modal-btn-confirm"
|
||||
disabled={!name.trim()}
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default CreateNotebookModal;
|
||||
99
client/src/components/CreateNotebookModal.test.jsx
Normal file
99
client/src/components/CreateNotebookModal.test.jsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import CreateNotebookModal from './CreateNotebookModal.jsx';
|
||||
|
||||
describe('CreateNotebookModal', () => {
|
||||
it('renders nothing when open is false', () => {
|
||||
const { container } = render(
|
||||
<CreateNotebookModal open={false} onConfirm={() => {}} onCancel={() => {}} />,
|
||||
);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('renders the modal with title when open', () => {
|
||||
render(<CreateNotebookModal open={true} onConfirm={() => {}} onCancel={() => {}} />);
|
||||
expect(screen.getByText('New Notebook')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders input and buttons', () => {
|
||||
render(<CreateNotebookModal open={true} onConfirm={() => {}} onCancel={() => {}} />);
|
||||
expect(screen.getByPlaceholderText('Enter notebook name')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cancel')).toBeInTheDocument();
|
||||
expect(screen.getByText('Create')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables Create button when input is empty', () => {
|
||||
render(<CreateNotebookModal open={true} onConfirm={() => {}} onCancel={() => {}} />);
|
||||
expect(screen.getByText('Create')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('enables Create button when input has text', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CreateNotebookModal open={true} onConfirm={() => {}} onCancel={() => {}} />);
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Enter notebook name'), 'My Notebook');
|
||||
expect(screen.getByText('Create')).toBeEnabled();
|
||||
});
|
||||
|
||||
it('calls onConfirm with trimmed name on submit', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onConfirm = vi.fn();
|
||||
render(<CreateNotebookModal open={true} onConfirm={onConfirm} onCancel={() => {}} />);
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Enter notebook name'), ' Research Notes ');
|
||||
await user.click(screen.getByText('Create'));
|
||||
expect(onConfirm).toHaveBeenCalledWith('Research Notes');
|
||||
});
|
||||
|
||||
it('calls onConfirm on Enter key', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onConfirm = vi.fn();
|
||||
render(<CreateNotebookModal open={true} onConfirm={onConfirm} onCancel={() => {}} />);
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Enter notebook name'), 'Test{Enter}');
|
||||
expect(onConfirm).toHaveBeenCalledWith('Test');
|
||||
});
|
||||
|
||||
it('does not call onConfirm when input is only whitespace', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onConfirm = vi.fn();
|
||||
render(<CreateNotebookModal open={true} onConfirm={onConfirm} onCancel={() => {}} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter notebook name');
|
||||
await user.type(input, ' ');
|
||||
await user.type(input, '{Enter}');
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls onCancel when Cancel button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onCancel = vi.fn();
|
||||
render(<CreateNotebookModal open={true} onConfirm={() => {}} onCancel={onCancel} />);
|
||||
|
||||
await user.click(screen.getByText('Cancel'));
|
||||
expect(onCancel).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('resets input when reopened', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(
|
||||
<CreateNotebookModal open={true} onConfirm={() => {}} onCancel={() => {}} />,
|
||||
);
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Enter notebook name'), 'Old name');
|
||||
|
||||
rerender(<CreateNotebookModal open={false} onConfirm={() => {}} onCancel={() => {}} />);
|
||||
rerender(<CreateNotebookModal open={true} onConfirm={() => {}} onCancel={() => {}} />);
|
||||
|
||||
expect(screen.getByPlaceholderText('Enter notebook name')).toHaveValue('');
|
||||
});
|
||||
|
||||
it('auto-focuses the input when opened', async () => {
|
||||
render(<CreateNotebookModal open={true} onConfirm={() => {}} onCancel={() => {}} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('Enter notebook name')).toHaveFocus();
|
||||
}, { timeout: 200 });
|
||||
});
|
||||
});
|
||||
29
client/src/components/DocumentButtons.jsx
Normal file
29
client/src/components/DocumentButtons.jsx
Normal file
@@ -0,0 +1,29 @@
|
||||
const DOCUMENT_TYPES = [
|
||||
{ type: 'study-guide', label: 'Study Guide' },
|
||||
{ type: 'faq', label: 'F.A.Q.' },
|
||||
{ type: 'executive-brief', label: 'Executive Brief' },
|
||||
];
|
||||
|
||||
function DocumentButtons({ sourceCount, onRequest, generatingType }) {
|
||||
const enabled = sourceCount >= 2;
|
||||
|
||||
return (
|
||||
<div className="document-buttons">
|
||||
{DOCUMENT_TYPES.map(({ type, label }) => {
|
||||
const isGenerating = generatingType === type;
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
className={`btn-document${isGenerating ? ' generating' : ''}`}
|
||||
disabled={!enabled || !!generatingType}
|
||||
onClick={() => onRequest(type)}
|
||||
>
|
||||
{isGenerating ? `Generating ${label}...` : label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DocumentButtons;
|
||||
61
client/src/components/DocumentButtons.test.jsx
Normal file
61
client/src/components/DocumentButtons.test.jsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import DocumentButtons from './DocumentButtons.jsx';
|
||||
|
||||
describe('DocumentButtons', () => {
|
||||
const LABELS = ['Study Guide', 'F.A.Q.', 'Executive Brief'];
|
||||
|
||||
it('renders all three document type buttons', () => {
|
||||
render(<DocumentButtons sourceCount={0} onRequest={() => {}} generatingType={null} />);
|
||||
LABELS.forEach((label) => {
|
||||
expect(screen.getByText(label)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('disables all buttons when sourceCount < 2', () => {
|
||||
render(<DocumentButtons sourceCount={1} onRequest={() => {}} generatingType={null} />);
|
||||
const buttons = screen.getAllByRole('button');
|
||||
buttons.forEach((btn) => expect(btn).toBeDisabled());
|
||||
});
|
||||
|
||||
it('enables buttons when sourceCount >= 2', () => {
|
||||
render(<DocumentButtons sourceCount={2} onRequest={() => {}} generatingType={null} />);
|
||||
const buttons = screen.getAllByRole('button');
|
||||
buttons.forEach((btn) => expect(btn).toBeEnabled());
|
||||
});
|
||||
|
||||
it('enables buttons for large source counts', () => {
|
||||
render(<DocumentButtons sourceCount={10} onRequest={() => {}} generatingType={null} />);
|
||||
const buttons = screen.getAllByRole('button');
|
||||
buttons.forEach((btn) => expect(btn).toBeEnabled());
|
||||
});
|
||||
|
||||
it('calls onRequest with the correct type when clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRequest = vi.fn();
|
||||
render(<DocumentButtons sourceCount={3} onRequest={onRequest} generatingType={null} />);
|
||||
|
||||
await user.click(screen.getByText('F.A.Q.'));
|
||||
expect(onRequest).toHaveBeenCalledWith('faq');
|
||||
});
|
||||
|
||||
it('disables all buttons when one type is generating', () => {
|
||||
render(<DocumentButtons sourceCount={5} onRequest={() => {}} generatingType="faq" />);
|
||||
const buttons = screen.getAllByRole('button');
|
||||
buttons.forEach((btn) => expect(btn).toBeDisabled());
|
||||
});
|
||||
|
||||
it('shows generating label for the active type', () => {
|
||||
render(<DocumentButtons sourceCount={5} onRequest={() => {}} generatingType="study-guide" />);
|
||||
expect(screen.getByText('Generating Study Guide...')).toBeInTheDocument();
|
||||
expect(screen.getByText('F.A.Q.')).toBeInTheDocument();
|
||||
expect(screen.getByText('Executive Brief')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies generating CSS class to the active button', () => {
|
||||
render(<DocumentButtons sourceCount={5} onRequest={() => {}} generatingType="executive-brief" />);
|
||||
const genButton = screen.getByText('Generating Executive Brief...');
|
||||
expect(genButton).toHaveClass('generating');
|
||||
});
|
||||
});
|
||||
112
client/src/components/DocumentModal.jsx
Normal file
112
client/src/components/DocumentModal.jsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import Modal from './Modal.jsx';
|
||||
|
||||
function StudyGuideContent({ doc }) {
|
||||
return (
|
||||
<div className="doc-content">
|
||||
<h2 className="doc-title">{doc.title}</h2>
|
||||
{doc.sections?.map((section, i) => (
|
||||
<section key={i} className="doc-section">
|
||||
<h3 className="doc-section-heading">{section.heading}</h3>
|
||||
{section.bullets?.length > 0 && (
|
||||
<ul className="doc-bullets">
|
||||
{section.bullets.map((b, j) => <li key={j}>{b}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
{section.keyTerms?.length > 0 && (
|
||||
<div className="doc-key-terms">
|
||||
<h4 className="doc-subsection-label">Key Terms</h4>
|
||||
<dl>
|
||||
{section.keyTerms.map((kt, j) => (
|
||||
<div key={j} className="doc-term-pair">
|
||||
<dt>{kt.term}</dt>
|
||||
<dd>{kt.definition}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
{section.reviewQuestions?.length > 0 && (
|
||||
<div className="doc-review">
|
||||
<h4 className="doc-subsection-label">Self-Review</h4>
|
||||
<ol>
|
||||
{section.reviewQuestions.map((q, j) => <li key={j}>{q}</li>)}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
{doc.mnemonics?.length > 0 && (
|
||||
<section className="doc-section">
|
||||
<h3 className="doc-section-heading">Memory Aids</h3>
|
||||
<ul className="doc-bullets">
|
||||
{doc.mnemonics.map((m, i) => <li key={i}>{m}</li>)}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FaqContent({ doc }) {
|
||||
return (
|
||||
<div className="doc-content">
|
||||
<h2 className="doc-title">FAQ: {doc.subject}</h2>
|
||||
{doc.faqPairs?.map((pair, i) => (
|
||||
<div key={i} className="doc-faq-pair">
|
||||
<h4 className="doc-faq-question">Q: {pair.question}</h4>
|
||||
<p className="doc-faq-answer">{pair.answer}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExecBriefContent({ doc }) {
|
||||
return (
|
||||
<div className="doc-content">
|
||||
<h2 className="doc-title">{doc.title}</h2>
|
||||
{doc.sections?.map((section, i) => (
|
||||
<section key={i} className="doc-section">
|
||||
<h3 className="doc-section-heading">{section.subhead}</h3>
|
||||
<p className="doc-prose">{section.prose}</p>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const LABELS = {
|
||||
'study-guide': 'Study Guide',
|
||||
'faq': 'F.A.Q.',
|
||||
'executive-brief': 'Executive Brief',
|
||||
};
|
||||
|
||||
const RENDERERS = {
|
||||
'study-guide': StudyGuideContent,
|
||||
'faq': FaqContent,
|
||||
'executive-brief': ExecBriefContent,
|
||||
};
|
||||
|
||||
function DocumentModal({ open, onClose, type, document: doc, loading }) {
|
||||
const Renderer = type ? RENDERERS[type] : null;
|
||||
const label = type ? LABELS[type] : '';
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={label} width="720px">
|
||||
{loading ? (
|
||||
<p className="citation-detail-loading">Generating {label}<span className="loading-dots" /></p>
|
||||
) : (
|
||||
<>
|
||||
{Renderer && doc && <Renderer doc={doc} />}
|
||||
<div className="modal-actions">
|
||||
<button className="modal-btn modal-btn-cancel" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default DocumentModal;
|
||||
140
client/src/components/DocumentModal.test.jsx
Normal file
140
client/src/components/DocumentModal.test.jsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import DocumentModal from './DocumentModal.jsx';
|
||||
|
||||
const studyGuideDoc = {
|
||||
title: 'Intro to AI',
|
||||
sections: [
|
||||
{
|
||||
heading: 'Chapter 1',
|
||||
bullets: ['Point A', 'Point B'],
|
||||
keyTerms: [{ term: 'Neural Net', definition: 'A model inspired by the brain' }],
|
||||
reviewQuestions: ['What is AI?'],
|
||||
},
|
||||
],
|
||||
mnemonics: ['AI = Always Iterating'],
|
||||
};
|
||||
|
||||
const faqDoc = {
|
||||
subject: 'Machine Learning',
|
||||
faqPairs: [
|
||||
{ question: 'What is ML?', answer: 'A subset of AI.' },
|
||||
{ question: 'Why use ML?', answer: 'To find patterns in data.' },
|
||||
],
|
||||
};
|
||||
|
||||
const execBriefDoc = {
|
||||
title: 'Q4 Strategy',
|
||||
sections: [
|
||||
{ subhead: 'Revenue', prose: 'Revenue grew 20%.' },
|
||||
{ subhead: 'Outlook', prose: 'Positive outlook for next quarter.' },
|
||||
],
|
||||
};
|
||||
|
||||
describe('DocumentModal', () => {
|
||||
it('renders nothing when open is false', () => {
|
||||
const { container } = render(
|
||||
<DocumentModal open={false} onClose={() => {}} type="faq" document={faqDoc} loading={false} />,
|
||||
);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('shows loading state with doc type label', () => {
|
||||
render(
|
||||
<DocumentModal open={true} onClose={() => {}} type="study-guide" document={null} loading={true} />,
|
||||
);
|
||||
expect(screen.getByText(/Generating Study Guide/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows loading state for FAQ', () => {
|
||||
render(
|
||||
<DocumentModal open={true} onClose={() => {}} type="faq" document={null} loading={true} />,
|
||||
);
|
||||
expect(screen.getByText(/Generating F\.A\.Q\./)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows loading state for Executive Brief', () => {
|
||||
render(
|
||||
<DocumentModal open={true} onClose={() => {}} type="executive-brief" document={null} loading={true} />,
|
||||
);
|
||||
expect(screen.getByText(/Generating Executive Brief/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('StudyGuideContent', () => {
|
||||
it('renders title and section heading', () => {
|
||||
render(
|
||||
<DocumentModal open={true} onClose={() => {}} type="study-guide" document={studyGuideDoc} loading={false} />,
|
||||
);
|
||||
expect(screen.getByText('Intro to AI')).toBeInTheDocument();
|
||||
expect(screen.getByText('Chapter 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders bullets', () => {
|
||||
render(
|
||||
<DocumentModal open={true} onClose={() => {}} type="study-guide" document={studyGuideDoc} loading={false} />,
|
||||
);
|
||||
expect(screen.getByText('Point A')).toBeInTheDocument();
|
||||
expect(screen.getByText('Point B')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders key terms', () => {
|
||||
render(
|
||||
<DocumentModal open={true} onClose={() => {}} type="study-guide" document={studyGuideDoc} loading={false} />,
|
||||
);
|
||||
expect(screen.getByText('Neural Net')).toBeInTheDocument();
|
||||
expect(screen.getByText('A model inspired by the brain')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders review questions', () => {
|
||||
render(
|
||||
<DocumentModal open={true} onClose={() => {}} type="study-guide" document={studyGuideDoc} loading={false} />,
|
||||
);
|
||||
expect(screen.getByText('What is AI?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders mnemonics', () => {
|
||||
render(
|
||||
<DocumentModal open={true} onClose={() => {}} type="study-guide" document={studyGuideDoc} loading={false} />,
|
||||
);
|
||||
expect(screen.getByText('AI = Always Iterating')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FaqContent', () => {
|
||||
it('renders subject and Q&A pairs', () => {
|
||||
render(
|
||||
<DocumentModal open={true} onClose={() => {}} type="faq" document={faqDoc} loading={false} />,
|
||||
);
|
||||
expect(screen.getByText('FAQ: Machine Learning')).toBeInTheDocument();
|
||||
expect(screen.getByText('Q: What is ML?')).toBeInTheDocument();
|
||||
expect(screen.getByText('A subset of AI.')).toBeInTheDocument();
|
||||
expect(screen.getByText('Q: Why use ML?')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ExecBriefContent', () => {
|
||||
it('renders title and sections', () => {
|
||||
render(
|
||||
<DocumentModal open={true} onClose={() => {}} type="executive-brief" document={execBriefDoc} loading={false} />,
|
||||
);
|
||||
expect(screen.getByText('Q4 Strategy')).toBeInTheDocument();
|
||||
expect(screen.getByText('Revenue')).toBeInTheDocument();
|
||||
expect(screen.getByText('Revenue grew 20%.')).toBeInTheDocument();
|
||||
expect(screen.getByText('Outlook')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders Close button when not loading', () => {
|
||||
render(
|
||||
<DocumentModal open={true} onClose={() => {}} type="faq" document={faqDoc} loading={false} />,
|
||||
);
|
||||
expect(screen.getByText('Close')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render Close button when loading', () => {
|
||||
render(
|
||||
<DocumentModal open={true} onClose={() => {}} type="faq" document={null} loading={true} />,
|
||||
);
|
||||
expect(screen.queryByText('Close')).toBeNull();
|
||||
});
|
||||
});
|
||||
16
client/src/components/FollowUpQuestions.jsx
Normal file
16
client/src/components/FollowUpQuestions.jsx
Normal file
@@ -0,0 +1,16 @@
|
||||
function FollowUpQuestions({ questions, onSelect }) {
|
||||
if (!questions || questions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="follow-up-questions">
|
||||
<h3 className="follow-up-header">Suggested Follow-Up Questions:</h3>
|
||||
{questions.map((q, i) => (
|
||||
<button key={i} className="follow-up-chip" onClick={() => onSelect(q)}>
|
||||
{q}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FollowUpQuestions;
|
||||
49
client/src/components/FollowUpQuestions.test.jsx
Normal file
49
client/src/components/FollowUpQuestions.test.jsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import FollowUpQuestions from './FollowUpQuestions.jsx';
|
||||
|
||||
describe('FollowUpQuestions', () => {
|
||||
it('renders nothing when questions is null', () => {
|
||||
const { container } = render(<FollowUpQuestions questions={null} onSelect={() => {}} />);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('renders nothing when questions is empty', () => {
|
||||
const { container } = render(<FollowUpQuestions questions={[]} onSelect={() => {}} />);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('renders a chip for each question', () => {
|
||||
const questions = ['What is X?', 'How does Y work?', 'Why Z?'];
|
||||
render(<FollowUpQuestions questions={questions} onSelect={() => {}} />);
|
||||
|
||||
questions.forEach((q) => {
|
||||
expect(screen.getByText(q)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders all chips as buttons', () => {
|
||||
const questions = ['Q1', 'Q2'];
|
||||
render(<FollowUpQuestions questions={questions} onSelect={() => {}} />);
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('calls onSelect with the question text when clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSelect = vi.fn();
|
||||
const questions = ['What is X?', 'How does Y work?'];
|
||||
|
||||
render(<FollowUpQuestions questions={questions} onSelect={onSelect} />);
|
||||
|
||||
await user.click(screen.getByText('How does Y work?'));
|
||||
expect(onSelect).toHaveBeenCalledOnce();
|
||||
expect(onSelect).toHaveBeenCalledWith('How does Y work?');
|
||||
});
|
||||
|
||||
it('renders the header text', () => {
|
||||
render(<FollowUpQuestions questions={['Q1']} onSelect={() => {}} />);
|
||||
expect(screen.getByText('Suggested Follow-Up Questions:')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
22
client/src/components/GroundednessScore.jsx
Normal file
22
client/src/components/GroundednessScore.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
const LEVEL_LABELS = {
|
||||
green: 'High score. Factually reliable response.',
|
||||
gold: 'Average score. Reliable response, may exhibit minor drift from source.',
|
||||
red: 'Low score. Unreliable response, factually inaccurate or hallucination.',
|
||||
};
|
||||
|
||||
function GroundednessScore({ score }) {
|
||||
if (score == null) return null;
|
||||
|
||||
const pct = Math.round(score * 100);
|
||||
let level = 'red';
|
||||
if (pct >= 75) level = 'green';
|
||||
else if (pct >= 50) level = 'gold';
|
||||
|
||||
return (
|
||||
<span className={`groundedness-score ${level}`}>
|
||||
Grounded: {pct}% – {LEVEL_LABELS[level]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default GroundednessScore;
|
||||
68
client/src/components/GroundednessScore.test.jsx
Normal file
68
client/src/components/GroundednessScore.test.jsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import GroundednessScore from './GroundednessScore.jsx';
|
||||
|
||||
describe('GroundednessScore', () => {
|
||||
it('renders nothing when score is null', () => {
|
||||
const { container } = render(<GroundednessScore score={null} />);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('renders nothing when score is undefined', () => {
|
||||
const { container } = render(<GroundednessScore />);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('renders green level for score >= 0.75', () => {
|
||||
render(<GroundednessScore score={0.85} />);
|
||||
const el = screen.getByText(/Grounded: 85%/);
|
||||
expect(el).toHaveClass('green');
|
||||
});
|
||||
|
||||
it('renders green at exactly 0.75 boundary', () => {
|
||||
render(<GroundednessScore score={0.75} />);
|
||||
const el = screen.getByText(/Grounded: 75%/);
|
||||
expect(el).toHaveClass('green');
|
||||
});
|
||||
|
||||
it('renders gold level for score >= 0.50 and < 0.75', () => {
|
||||
render(<GroundednessScore score={0.6} />);
|
||||
const el = screen.getByText(/Grounded: 60%/);
|
||||
expect(el).toHaveClass('gold');
|
||||
});
|
||||
|
||||
it('renders gold at exactly 0.50 boundary', () => {
|
||||
render(<GroundednessScore score={0.5} />);
|
||||
const el = screen.getByText(/Grounded: 50%/);
|
||||
expect(el).toHaveClass('gold');
|
||||
});
|
||||
|
||||
it('renders red level for score < 0.50', () => {
|
||||
render(<GroundednessScore score={0.3} />);
|
||||
const el = screen.getByText(/Grounded: 30%/);
|
||||
expect(el).toHaveClass('red');
|
||||
});
|
||||
|
||||
it('renders 0% for score of 0', () => {
|
||||
render(<GroundednessScore score={0} />);
|
||||
const el = screen.getByText(/Grounded: 0%/);
|
||||
expect(el).toHaveClass('red');
|
||||
});
|
||||
|
||||
it('renders 100% for score of 1', () => {
|
||||
render(<GroundednessScore score={1} />);
|
||||
const el = screen.getByText(/Grounded: 100%/);
|
||||
expect(el).toHaveClass('green');
|
||||
});
|
||||
|
||||
it('includes the correct label text for each level', () => {
|
||||
const { rerender } = render(<GroundednessScore score={0.9} />);
|
||||
expect(screen.getByText(/Factually reliable response/)).toBeInTheDocument();
|
||||
|
||||
rerender(<GroundednessScore score={0.6} />);
|
||||
expect(screen.getByText(/minor drift from source/)).toBeInTheDocument();
|
||||
|
||||
rerender(<GroundednessScore score={0.2} />);
|
||||
expect(screen.getByText(/factually inaccurate or hallucination/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
68
client/src/components/Modal.jsx
Normal file
68
client/src/components/Modal.jsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
const FOCUSABLE = 'a[href], button:not(:disabled), input:not(:disabled), textarea:not(:disabled), select:not(:disabled), [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
function Modal({ open, onClose, title, width, children }) {
|
||||
const dialogRef = useRef(null);
|
||||
const previousFocus = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
previousFocus.current = document.activeElement;
|
||||
const dialog = dialogRef.current;
|
||||
const first = dialog?.querySelector(FOCUSABLE);
|
||||
if (first) first.focus();
|
||||
else dialog?.focus();
|
||||
|
||||
return () => previousFocus.current?.focus();
|
||||
}, [open]);
|
||||
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (e.key !== 'Tab') return;
|
||||
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
const focusable = [...dialog.querySelectorAll(FOCUSABLE)];
|
||||
if (focusable.length === 0) return;
|
||||
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}, [onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const handleOverlayClick = (e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={handleOverlayClick} onKeyDown={handleKeyDown}>
|
||||
<div
|
||||
className="modal-dialog"
|
||||
ref={dialogRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title || undefined}
|
||||
tabIndex={-1}
|
||||
style={width ? { width } : undefined}
|
||||
>
|
||||
{title && <h3 className="modal-title">{title}</h3>}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Modal;
|
||||
103
client/src/components/Modal.test.jsx
Normal file
103
client/src/components/Modal.test.jsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import Modal from './Modal.jsx';
|
||||
|
||||
describe('Modal', () => {
|
||||
it('renders nothing when open is false', () => {
|
||||
const { container } = render(
|
||||
<Modal open={false} onClose={() => {}} title="Hidden">
|
||||
<p>Content</p>
|
||||
</Modal>,
|
||||
);
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('renders children when open is true', () => {
|
||||
render(
|
||||
<Modal open={true} onClose={() => {}}>
|
||||
<p>Hello world</p>
|
||||
</Modal>,
|
||||
);
|
||||
expect(screen.getByText('Hello world')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the title when provided', () => {
|
||||
render(
|
||||
<Modal open={true} onClose={() => {}} title="My Modal">
|
||||
<p>body</p>
|
||||
</Modal>,
|
||||
);
|
||||
expect(screen.getByText('My Modal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render a title element when title is omitted', () => {
|
||||
render(
|
||||
<Modal open={true} onClose={() => {}}>
|
||||
<p>body</p>
|
||||
</Modal>,
|
||||
);
|
||||
expect(screen.queryByRole('heading')).toBeNull();
|
||||
});
|
||||
|
||||
it('has dialog role and aria-modal', () => {
|
||||
render(
|
||||
<Modal open={true} onClose={() => {}} title="Accessible">
|
||||
<p>body</p>
|
||||
</Modal>,
|
||||
);
|
||||
const dialog = screen.getByRole('dialog');
|
||||
expect(dialog).toHaveAttribute('aria-modal', 'true');
|
||||
expect(dialog).toHaveAttribute('aria-label', 'Accessible');
|
||||
});
|
||||
|
||||
it('calls onClose when Escape is pressed', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<Modal open={true} onClose={onClose}>
|
||||
<p>body</p>
|
||||
</Modal>,
|
||||
);
|
||||
|
||||
await user.keyboard('{Escape}');
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('calls onClose when overlay is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
const { container } = render(
|
||||
<Modal open={true} onClose={onClose}>
|
||||
<p>body</p>
|
||||
</Modal>,
|
||||
);
|
||||
|
||||
const overlay = container.querySelector('.modal-overlay');
|
||||
await user.click(overlay);
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not call onClose when dialog content is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<Modal open={true} onClose={onClose}>
|
||||
<p>body</p>
|
||||
</Modal>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('body'));
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies custom width style', () => {
|
||||
render(
|
||||
<Modal open={true} onClose={() => {}} width="600px">
|
||||
<p>body</p>
|
||||
</Modal>,
|
||||
);
|
||||
const dialog = screen.getByRole('dialog');
|
||||
expect(dialog.style.width).toBe('600px');
|
||||
});
|
||||
});
|
||||
53
client/src/components/NotebookList.jsx
Normal file
53
client/src/components/NotebookList.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import { useState } from 'react';
|
||||
import CreateNotebookModal from './CreateNotebookModal.jsx';
|
||||
|
||||
function NotebookList({ notebooks, activeId, onSelect, onCreate, onDelete }) {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
const handleDelete = (e, id) => {
|
||||
e.stopPropagation();
|
||||
onDelete(id);
|
||||
};
|
||||
|
||||
const handleConfirm = (name) => {
|
||||
setModalOpen(false);
|
||||
onCreate(name);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="notebook-list">
|
||||
<h2>Notebooks</h2>
|
||||
<ul>
|
||||
{notebooks.map((nb) => (
|
||||
<li
|
||||
key={nb.id}
|
||||
className={nb.id === activeId ? 'active' : ''}
|
||||
onClick={() => onSelect(nb.id)}
|
||||
>
|
||||
<span className="nb-name">{nb.name}</span>
|
||||
<button
|
||||
className="nb-delete"
|
||||
onClick={(e) => handleDelete(e, nb.id)}
|
||||
title="Delete notebook"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button
|
||||
className="btn-new-notebook"
|
||||
onClick={() => setModalOpen(true)}
|
||||
>
|
||||
+ New Notebook
|
||||
</button>
|
||||
<CreateNotebookModal
|
||||
open={modalOpen}
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NotebookList;
|
||||
208
client/src/components/SourcePanel.jsx
Normal file
208
client/src/components/SourcePanel.jsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import * as sourcesApi from '../api/sources.js';
|
||||
|
||||
const ALLOWED_EXTENSIONS = ['.pdf', '.txt', '.md', '.docx', '.mp3', '.wav', '.m4a', '.ogg', '.flac', '.webm'];
|
||||
|
||||
const FILE_ACCEPT = ALLOWED_EXTENSIONS.join(',');
|
||||
|
||||
function isFileAllowed(file) {
|
||||
const ext = file.name.slice(file.name.lastIndexOf('.')).toLowerCase();
|
||||
return ALLOWED_EXTENSIONS.includes(ext);
|
||||
}
|
||||
|
||||
function SourcePanel({ notebookId, hoveredSourceIndex, onSourceHover, onSourcesChange, children }) {
|
||||
const [sources, setSources] = useState([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [urlInput, setUrlInput] = useState('');
|
||||
const [addingUrl, setAddingUrl] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const fileRef = useRef(null);
|
||||
const dragCounter = useRef(0);
|
||||
const errorTimer = useRef(null);
|
||||
|
||||
const showError = (msg) => {
|
||||
setError(msg);
|
||||
clearTimeout(errorTimer.current);
|
||||
errorTimer.current = setTimeout(() => setError(null), 8000);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setSources([]);
|
||||
onSourcesChange?.(0);
|
||||
sourcesApi.listSources(notebookId).then((s) => {
|
||||
setSources(s);
|
||||
onSourcesChange?.(s.length);
|
||||
}).catch((err) => showError(err.message || 'Failed to load sources'));
|
||||
}, [notebookId, onSourcesChange]);
|
||||
|
||||
useEffect(() => () => clearTimeout(errorTimer.current), []);
|
||||
|
||||
const uploadFile = async (file) => {
|
||||
if (!file) return;
|
||||
if (!isFileAllowed(file)) {
|
||||
showError(`Unsupported file type. Allowed: ${ALLOWED_EXTENSIONS.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setUploading(true);
|
||||
try {
|
||||
const src = await sourcesApi.uploadSource(notebookId, file);
|
||||
setSources((prev) => {
|
||||
const next = [...prev, src];
|
||||
onSourcesChange?.(next.length);
|
||||
return next;
|
||||
});
|
||||
} catch (err) {
|
||||
showError(err.message || 'Upload failed');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileRef.current) fileRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = (e) => {
|
||||
uploadFile(e.target.files?.[0]);
|
||||
};
|
||||
|
||||
const handleAddUrl = async (e) => {
|
||||
e.preventDefault();
|
||||
const url = urlInput.trim();
|
||||
if (!url || addingUrl) return;
|
||||
setError(null);
|
||||
setAddingUrl(true);
|
||||
try {
|
||||
const src = await sourcesApi.addUrlSource(notebookId, url);
|
||||
if (src.error) {
|
||||
showError(src.error);
|
||||
} else {
|
||||
setSources((prev) => {
|
||||
const next = [...prev, src];
|
||||
onSourcesChange?.(next.length);
|
||||
return next;
|
||||
});
|
||||
setUrlInput('');
|
||||
}
|
||||
} catch (err) {
|
||||
showError(err.message || 'Failed to add URL');
|
||||
} finally {
|
||||
setAddingUrl(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnter = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounter.current++;
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragCounter.current--;
|
||||
if (dragCounter.current === 0) setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDragOver = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleDrop = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
dragCounter.current = 0;
|
||||
if (uploading) return;
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
uploadFile(file);
|
||||
};
|
||||
|
||||
const busy = uploading || addingUrl;
|
||||
|
||||
return (
|
||||
<div className="source-panel">
|
||||
<h3>Sources</h3>
|
||||
<ul role="list">
|
||||
{sources.map((s, idx) => {
|
||||
const docIndex = idx + 1;
|
||||
const isHighlighted = hoveredSourceIndex === docIndex;
|
||||
return (
|
||||
<li
|
||||
key={s.id}
|
||||
className={`source-item${isHighlighted ? ' source-highlighted' : ''}`}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-label={`Source ${docIndex}: ${s.name}`}
|
||||
onMouseEnter={() => onSourceHover(docIndex)}
|
||||
onMouseLeave={() => onSourceHover(null)}
|
||||
onFocus={() => onSourceHover(docIndex)}
|
||||
onBlur={() => onSourceHover(null)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onSourceHover(docIndex);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="source-number">{docIndex}</span>
|
||||
{s.name}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<div
|
||||
className={`source-upload-area${isDragging ? ' dragging' : ''}`}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept={FILE_ACCEPT}
|
||||
onChange={handleUpload}
|
||||
hidden
|
||||
/>
|
||||
<button
|
||||
className="btn-upload"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
disabled={busy}
|
||||
>
|
||||
{uploading ? 'Uploading...' : '+ Upload Source'}
|
||||
</button>
|
||||
<div className="drop-zone">
|
||||
[ or drag and drop ]
|
||||
</div>
|
||||
</div>
|
||||
<form className="source-url-form" onSubmit={handleAddUrl}>
|
||||
<input
|
||||
className="source-url-input"
|
||||
type="text"
|
||||
placeholder="Paste a URL or YouTube link..."
|
||||
value={urlInput}
|
||||
onChange={(e) => setUrlInput(e.target.value)}
|
||||
disabled={busy}
|
||||
/>
|
||||
<button
|
||||
className="btn-add-url"
|
||||
type="submit"
|
||||
disabled={busy || !urlInput.trim()}
|
||||
>
|
||||
{addingUrl ? 'Adding...' : '+ Add'}
|
||||
</button>
|
||||
</form>
|
||||
{error && (
|
||||
<div className="source-error" role="alert">
|
||||
<span>{error}</span>
|
||||
<button className="source-error-dismiss" onClick={() => setError(null)} aria-label="Dismiss">×</button>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SourcePanel;
|
||||
45
client/src/components/SourcePanel.test.jsx
Normal file
45
client/src/components/SourcePanel.test.jsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
// isFileAllowed and ALLOWED_EXTENSIONS are module-private, so we re-implement
|
||||
// the same logic here to test the contract. If they were exported we'd import
|
||||
// them directly. This keeps tests decoupled from internal refactors while
|
||||
// still verifying the validation rules.
|
||||
|
||||
const ALLOWED_EXTENSIONS = ['.pdf', '.txt', '.md', '.docx', '.mp3', '.wav', '.m4a', '.ogg', '.flac', '.webm'];
|
||||
|
||||
function isFileAllowed(file) {
|
||||
const ext = file.name.slice(file.name.lastIndexOf('.')).toLowerCase();
|
||||
return ALLOWED_EXTENSIONS.includes(ext);
|
||||
}
|
||||
|
||||
function fakeFile(name) {
|
||||
return { name };
|
||||
}
|
||||
|
||||
describe('isFileAllowed', () => {
|
||||
it.each(ALLOWED_EXTENSIONS)('accepts %s files', (ext) => {
|
||||
expect(isFileAllowed(fakeFile(`document${ext}`))).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects unsupported extensions', () => {
|
||||
expect(isFileAllowed(fakeFile('virus.exe'))).toBe(false);
|
||||
expect(isFileAllowed(fakeFile('archive.zip'))).toBe(false);
|
||||
expect(isFileAllowed(fakeFile('image.png'))).toBe(false);
|
||||
expect(isFileAllowed(fakeFile('data.csv'))).toBe(false);
|
||||
});
|
||||
|
||||
it('is case-insensitive', () => {
|
||||
expect(isFileAllowed(fakeFile('README.TXT'))).toBe(true);
|
||||
expect(isFileAllowed(fakeFile('report.PDF'))).toBe(true);
|
||||
expect(isFileAllowed(fakeFile('notes.Md'))).toBe(true);
|
||||
});
|
||||
|
||||
it('handles filenames with multiple dots', () => {
|
||||
expect(isFileAllowed(fakeFile('my.report.final.pdf'))).toBe(true);
|
||||
expect(isFileAllowed(fakeFile('archive.tar.gz'))).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects files with no extension', () => {
|
||||
expect(isFileAllowed(fakeFile('Makefile'))).toBe(false);
|
||||
});
|
||||
});
|
||||
50
client/src/hooks/useNotebook.js
Normal file
50
client/src/hooks/useNotebook.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import * as notebooksApi from '../api/notebooks.js';
|
||||
|
||||
export function useNotebook() {
|
||||
const [notebooks, setNotebooks] = useState([]);
|
||||
const [activeNotebook, setActiveNotebook] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
notebooksApi.listNotebooks().then(setNotebooks).catch(console.error);
|
||||
}, []);
|
||||
|
||||
const selectNotebook = useCallback(
|
||||
(id) => {
|
||||
const nb = notebooks.find((n) => n.id === id) || null;
|
||||
setActiveNotebook(nb);
|
||||
},
|
||||
[notebooks],
|
||||
);
|
||||
|
||||
const createNotebook = useCallback(async (name) => {
|
||||
if (!name) return;
|
||||
const nb = await notebooksApi.createNotebook(name);
|
||||
setNotebooks((prev) => [...prev, nb]);
|
||||
setActiveNotebook(nb);
|
||||
}, []);
|
||||
|
||||
const deleteNotebook = useCallback(
|
||||
async (id) => {
|
||||
try {
|
||||
await notebooksApi.deleteNotebook(id);
|
||||
} catch (err) {
|
||||
console.error('Failed to delete notebook', err);
|
||||
return;
|
||||
}
|
||||
setNotebooks((prev) => prev.filter((n) => n.id !== id));
|
||||
if (activeNotebook?.id === id) {
|
||||
setActiveNotebook(null);
|
||||
}
|
||||
},
|
||||
[activeNotebook],
|
||||
);
|
||||
|
||||
return {
|
||||
notebooks,
|
||||
activeNotebook,
|
||||
selectNotebook,
|
||||
createNotebook,
|
||||
deleteNotebook,
|
||||
};
|
||||
}
|
||||
176
client/src/hooks/useNotebook.test.js
Normal file
176
client/src/hooks/useNotebook.test.js
Normal file
@@ -0,0 +1,176 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
|
||||
vi.mock('../api/notebooks.js', () => ({
|
||||
listNotebooks: vi.fn(),
|
||||
createNotebook: vi.fn(),
|
||||
deleteNotebook: vi.fn(),
|
||||
}));
|
||||
|
||||
import { useNotebook } from './useNotebook.js';
|
||||
import * as notebooksApi from '../api/notebooks.js';
|
||||
|
||||
const notebooks = [
|
||||
{ id: 'nb-1', name: 'Research' },
|
||||
{ id: 'nb-2', name: 'Personal' },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
notebooksApi.listNotebooks.mockResolvedValue(notebooks);
|
||||
notebooksApi.createNotebook.mockImplementation(async (name) => ({
|
||||
id: `nb-${Date.now()}`,
|
||||
name,
|
||||
}));
|
||||
notebooksApi.deleteNotebook.mockResolvedValue({});
|
||||
});
|
||||
|
||||
describe('useNotebook', () => {
|
||||
it('loads notebooks on mount', async () => {
|
||||
const { result } = renderHook(() => useNotebook());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.notebooks).toEqual(notebooks);
|
||||
});
|
||||
expect(notebooksApi.listNotebooks).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('starts with no active notebook', () => {
|
||||
const { result } = renderHook(() => useNotebook());
|
||||
expect(result.current.activeNotebook).toBeNull();
|
||||
});
|
||||
|
||||
it('selectNotebook sets the active notebook', async () => {
|
||||
const { result } = renderHook(() => useNotebook());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.notebooks).toHaveLength(2);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.selectNotebook('nb-2');
|
||||
});
|
||||
|
||||
expect(result.current.activeNotebook).toEqual({ id: 'nb-2', name: 'Personal' });
|
||||
});
|
||||
|
||||
it('selectNotebook sets null for unknown id', async () => {
|
||||
const { result } = renderHook(() => useNotebook());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.notebooks).toHaveLength(2);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.selectNotebook('nb-2');
|
||||
});
|
||||
expect(result.current.activeNotebook).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
result.current.selectNotebook('nonexistent');
|
||||
});
|
||||
expect(result.current.activeNotebook).toBeNull();
|
||||
});
|
||||
|
||||
it('createNotebook calls API and adds to list', async () => {
|
||||
const newNb = { id: 'nb-new', name: 'New One' };
|
||||
notebooksApi.createNotebook.mockResolvedValue(newNb);
|
||||
|
||||
const { result } = renderHook(() => useNotebook());
|
||||
await waitFor(() => expect(result.current.notebooks).toHaveLength(2));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.createNotebook('New One');
|
||||
});
|
||||
|
||||
expect(notebooksApi.createNotebook).toHaveBeenCalledWith('New One');
|
||||
expect(result.current.notebooks).toHaveLength(3);
|
||||
expect(result.current.notebooks[2]).toEqual(newNb);
|
||||
expect(result.current.activeNotebook).toEqual(newNb);
|
||||
});
|
||||
|
||||
it('createNotebook does nothing for empty name', async () => {
|
||||
const { result } = renderHook(() => useNotebook());
|
||||
await waitFor(() => expect(result.current.notebooks).toHaveLength(2));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.createNotebook('');
|
||||
});
|
||||
|
||||
expect(notebooksApi.createNotebook).not.toHaveBeenCalled();
|
||||
expect(result.current.notebooks).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('deleteNotebook removes from list', async () => {
|
||||
const { result } = renderHook(() => useNotebook());
|
||||
await waitFor(() => expect(result.current.notebooks).toHaveLength(2));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deleteNotebook('nb-1');
|
||||
});
|
||||
|
||||
expect(notebooksApi.deleteNotebook).toHaveBeenCalledWith('nb-1');
|
||||
expect(result.current.notebooks).toHaveLength(1);
|
||||
expect(result.current.notebooks[0].id).toBe('nb-2');
|
||||
});
|
||||
|
||||
it('deleteNotebook clears activeNotebook if it was the deleted one', async () => {
|
||||
const { result } = renderHook(() => useNotebook());
|
||||
await waitFor(() => expect(result.current.notebooks).toHaveLength(2));
|
||||
|
||||
act(() => {
|
||||
result.current.selectNotebook('nb-1');
|
||||
});
|
||||
expect(result.current.activeNotebook?.id).toBe('nb-1');
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deleteNotebook('nb-1');
|
||||
});
|
||||
|
||||
expect(result.current.activeNotebook).toBeNull();
|
||||
});
|
||||
|
||||
it('deleteNotebook preserves activeNotebook if different one deleted', async () => {
|
||||
const { result } = renderHook(() => useNotebook());
|
||||
await waitFor(() => expect(result.current.notebooks).toHaveLength(2));
|
||||
|
||||
act(() => {
|
||||
result.current.selectNotebook('nb-2');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deleteNotebook('nb-1');
|
||||
});
|
||||
|
||||
expect(result.current.activeNotebook).toEqual({ id: 'nb-2', name: 'Personal' });
|
||||
});
|
||||
|
||||
it('deleteNotebook does not remove from list on API error', async () => {
|
||||
notebooksApi.deleteNotebook.mockRejectedValue(new Error('Server error'));
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const { result } = renderHook(() => useNotebook());
|
||||
await waitFor(() => expect(result.current.notebooks).toHaveLength(2));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deleteNotebook('nb-1');
|
||||
});
|
||||
|
||||
expect(result.current.notebooks).toHaveLength(2);
|
||||
console.error.mockRestore();
|
||||
});
|
||||
|
||||
it('handles listNotebooks API failure gracefully', async () => {
|
||||
notebooksApi.listNotebooks.mockRejectedValue(new Error('Network error'));
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const { result } = renderHook(() => useNotebook());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(notebooksApi.listNotebooks).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(result.current.notebooks).toEqual([]);
|
||||
console.error.mockRestore();
|
||||
});
|
||||
});
|
||||
873
client/src/index.css
Normal file
873
client/src/index.css
Normal file
@@ -0,0 +1,873 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Nunito+Sans:ital,opsz,wght@0,6..12,200..1000;1,6..12,200..1000&display=swap');
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #f8f9fa;
|
||||
--surface: #ffffff;
|
||||
--sidebar-bg: #000000;
|
||||
--sidebar-text: #e240ff;
|
||||
--sidebar-hover: #7b7b7b;
|
||||
--sidebar-active: #3c3d3e;
|
||||
--primary: #000000;
|
||||
--primary-hover: #d43aa6;
|
||||
--border: #dee2e6;
|
||||
--text: #212529;
|
||||
--text-muted: #6c757d;
|
||||
--danger: #dc3545;
|
||||
--green: #3eff05;
|
||||
--gold: #e6a817;
|
||||
--red: #dc3545;
|
||||
--radius: 8px;
|
||||
--radius-sm: 4px;
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
|
||||
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Nunito Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
||||
Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
/* ── Layout ────────────────────────────────────────── */
|
||||
|
||||
.app {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 340px;
|
||||
min-width: 320px;
|
||||
background: var(--sidebar-bg);
|
||||
color: var(--sidebar-text);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main>p {
|
||||
margin: auto;
|
||||
color: var(--text-muted);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* ── Notebook List ─────────────────────────────────── */
|
||||
|
||||
.notebook-list {
|
||||
padding: 20px 16px 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.notebook-list h2 {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #e240ff;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.notebook-list ul {
|
||||
list-style: none;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.notebook-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.notebook-list li:hover {
|
||||
background: var(--sidebar-hover);
|
||||
}
|
||||
|
||||
.notebook-list li.active {
|
||||
background: var(--sidebar-active);
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.notebook-list li .nb-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.notebook-list li .nb-delete {
|
||||
opacity: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
transition: opacity 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.notebook-list li:hover .nb-delete {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.notebook-list li .nb-delete:hover {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.btn-new-notebook {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px dashed rgba(255, 255, 255, 0.2);
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--sidebar-text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.btn-new-notebook:hover {
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ── Source Panel ───────────────────────────────────── */
|
||||
|
||||
.source-panel {
|
||||
padding: 16px;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.source-panel h3 {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #e240ff;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.source-panel ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.source-panel li {
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
.source-panel li .source-number {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
min-width: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.source-panel li {
|
||||
cursor: pointer;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.source-panel li:hover .source-number,
|
||||
.source-panel li.source-highlighted .source-number {
|
||||
background: transparent;
|
||||
border-color: #04d9ff;
|
||||
color: #04d9ff;
|
||||
}
|
||||
|
||||
.source-panel li:hover,
|
||||
.source-panel li.source-highlighted {
|
||||
color: #04d9ff;
|
||||
}
|
||||
|
||||
.source-upload-area {
|
||||
margin-top: 8px;
|
||||
border: 1px dashed rgba(255, 255, 255, 0.2);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.source-upload-area.dragging {
|
||||
border-color: var(--sidebar-text);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.btn-upload {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--sidebar-text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.btn-upload:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-upload:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.drop-zone {
|
||||
padding: 0px 0px 12px 8px;
|
||||
text-align: center;
|
||||
color: var(--sidebar-text);
|
||||
font-size: 13px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.source-url-form {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 8px 0 0;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.source-url-input {
|
||||
flex: 1;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.source-url-input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.source-url-input:focus {
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.btn-add-url {
|
||||
padding: 7px 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--sidebar-text);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.btn-add-url:hover:not(:disabled) {
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-add-url:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.source-error {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 60, 60, 0.12);
|
||||
border: 1px solid rgba(255, 60, 60, 0.3);
|
||||
color: #ff6b6b;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
animation: source-error-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
.source-error span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.source-error-dismiss {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #ff6b6b;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
padding: 0 2px;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.source-error-dismiss:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@keyframes source-error-in {
|
||||
from { opacity: 0; transform: translateY(-4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* ── Document Buttons ─────────────────────────────── */
|
||||
|
||||
.document-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.btn-document {
|
||||
padding: 10px 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s, opacity 0.15s;
|
||||
}
|
||||
|
||||
.btn-document:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.btn-document:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-document.generating {
|
||||
border-color: #e240ff;
|
||||
color: #e240ff;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ── Chat Panel ────────────────────────────────────── */
|
||||
|
||||
.chat-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px 32px;
|
||||
}
|
||||
|
||||
.chat-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-muted);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 16px 32px 24px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.chat-input input {
|
||||
flex: 1;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.chat-input input:focus {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.chat-input button {
|
||||
padding: 10px 20px;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.chat-input button:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.chat-input button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Chat Message ──────────────────────────────────── */
|
||||
|
||||
.chat-message {
|
||||
margin-bottom: 20px;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.chat-message.user {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
padding: 10px 16px;
|
||||
border-radius: var(--radius) var(--radius) var(--radius-sm) var(--radius);
|
||||
margin-left: auto;
|
||||
max-width: 480px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.chat-message.assistant {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
padding: 16px 20px;
|
||||
border-radius: var(--radius-sm) var(--radius) var(--radius) var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.chat-message.assistant .answer-text {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.65;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.citation-marker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
vertical-align: super;
|
||||
margin: 0 1px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.citation-marker:hover,
|
||||
.citation-marker.citation-highlighted {
|
||||
background: transparent;
|
||||
outline: 2px solid #04d9ff;
|
||||
color: #04d9ff;
|
||||
}
|
||||
|
||||
.message-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ── Groundedness Score ────────────────────────────── */
|
||||
|
||||
.groundedness-score {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 3px 10px;
|
||||
border-radius: 100px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.groundedness-score.green {
|
||||
background: rgba(40, 167, 69, 0.1);
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
.groundedness-score.gold {
|
||||
background: rgba(230, 168, 23, 0.1);
|
||||
color: var(--gold);
|
||||
}
|
||||
|
||||
.groundedness-score.red {
|
||||
background: rgba(220, 53, 69, 0.1);
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
/* ── Follow-up Questions ───────────────────────────── */
|
||||
|
||||
.follow-up-questions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.follow-up-header {
|
||||
margin-bottom: 8px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.follow-up-chip {
|
||||
padding: 6px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 100px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.follow-up-chip:hover {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ── Loading indicator ─────────────────────────────── */
|
||||
|
||||
.loading-dots::after {
|
||||
content: '';
|
||||
animation: dots 1.2s steps(4, end) infinite;
|
||||
}
|
||||
|
||||
@keyframes dots {
|
||||
0% {
|
||||
content: '';
|
||||
}
|
||||
|
||||
25% {
|
||||
content: '.';
|
||||
}
|
||||
|
||||
50% {
|
||||
content: '..';
|
||||
}
|
||||
|
||||
75% {
|
||||
content: '...';
|
||||
}
|
||||
}
|
||||
|
||||
.chat-message.loading {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Modal ────────────────────────────────────────── */
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-dialog {
|
||||
background: #1e1e1e;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px;
|
||||
width: 360px;
|
||||
max-width: 90vw;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.modal-input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: var(--radius-sm);
|
||||
background: #2a2a2a;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.modal-input:focus {
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.modal-input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.modal-btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: none;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
}
|
||||
|
||||
.modal-btn-cancel {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.modal-btn-cancel:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.modal-btn-confirm {
|
||||
background: #fff;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.modal-btn-confirm:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.modal-btn-confirm:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Citation Detail Modal ────────────────────────── */
|
||||
|
||||
.citation-detail-loading {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-style: italic;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.citation-detail-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.citation-detail-section h4.citation-detail-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #e240ff;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.citation-detail-section p {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.citation-detail-quote {
|
||||
border-left: 3px solid #e240ff;
|
||||
padding: 8px 14px;
|
||||
margin: 0;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.citation-detail-links {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.citation-detail-links a {
|
||||
color: #04d9ff;
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.citation-detail-links a:hover {
|
||||
opacity: 0.75;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ── Document Modal Content ───────────────────────── */
|
||||
|
||||
.doc-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.doc-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.doc-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.doc-section-heading {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #e240ff;
|
||||
}
|
||||
|
||||
.doc-subsection-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.doc-bullets {
|
||||
padding-left: 20px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.doc-bullets li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.doc-key-terms dl {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.doc-term-pair {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.doc-term-pair dt {
|
||||
font-weight: 600;
|
||||
color: #04d9ff;
|
||||
min-width: 120px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.doc-term-pair dd {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.doc-review ol {
|
||||
padding-left: 20px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.doc-review ol li {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.doc-faq-pair {
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.doc-faq-pair:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.doc-faq-question {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #e240ff;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.doc-faq-answer {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.doc-prose {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
10
client/src/main.jsx
Normal file
10
client/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App.jsx';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
7
client/src/test/setup.js
Normal file
7
client/src/test/setup.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
import { afterEach } from 'vitest';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
Reference in New Issue
Block a user