diff --git a/frontend/src/features/chunks/api.test.ts b/frontend/src/features/chunks/api.test.ts new file mode 100644 index 0000000..3b0c2ab --- /dev/null +++ b/frontend/src/features/chunks/api.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { + fetchChunks, + updateChunk, + mergeChunks, + splitChunk, + dropChunk, + addChunk, + fetchChunkDiff, + pushChunksToStore, +} from './api' + +vi.mock('../../shared/api/http', () => ({ + apiFetch: vi.fn(), +})) + +import { apiFetch } from '../../shared/api/http' + +describe('chunks API', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('fetchChunks calls GET /api/documents/:id/chunks', async () => { + const chunks = [{ id: 'c1', docId: 'd1', text: 'hello' }] + apiFetch.mockResolvedValue(chunks) + + const result = await fetchChunks('d1') + + expect(apiFetch).toHaveBeenCalledWith('/api/documents/d1/chunks') + expect(result).toEqual(chunks) + }) + + it('updateChunk calls PATCH with patch body', async () => { + const chunk = { id: 'c1', docId: 'd1', text: 'updated' } + apiFetch.mockResolvedValue(chunk) + + const result = await updateChunk('d1', 'c1', { text: 'updated' }) + + expect(apiFetch).toHaveBeenCalledWith('/api/documents/d1/chunks/c1', { + method: 'PATCH', + body: JSON.stringify({ text: 'updated' }), + }) + expect(result).toEqual(chunk) + }) + + it('mergeChunks calls POST /chunks/merge with ids', async () => { + const merged = [{ id: 'cm', docId: 'd1', text: 'merged' }] + apiFetch.mockResolvedValue(merged) + + const result = await mergeChunks('d1', ['c1', 'c2']) + + expect(apiFetch).toHaveBeenCalledWith('/api/documents/d1/chunks/merge', { + method: 'POST', + body: JSON.stringify({ ids: ['c1', 'c2'] }), + }) + expect(result).toEqual(merged) + }) + + it('splitChunk calls POST /chunks/:id/split with cursorOffset', async () => { + const split = [ + { id: 'c1a', docId: 'd1', text: 'part1' }, + { id: 'c1b', docId: 'd1', text: 'part2' }, + ] + apiFetch.mockResolvedValue(split) + + const result = await splitChunk('d1', 'c1', 10) + + expect(apiFetch).toHaveBeenCalledWith('/api/documents/d1/chunks/c1/split', { + method: 'POST', + body: JSON.stringify({ cursorOffset: 10 }), + }) + expect(result).toEqual(split) + }) + + it('dropChunk calls DELETE /api/documents/:id/chunks/:chunkId', async () => { + apiFetch.mockResolvedValue(undefined) + + await dropChunk('d1', 'c1') + + expect(apiFetch).toHaveBeenCalledWith('/api/documents/d1/chunks/c1', { method: 'DELETE' }) + }) + + it('addChunk calls POST /chunks with text and afterId', async () => { + const newChunk = { id: 'cnew', docId: 'd1', text: 'new' } + apiFetch.mockResolvedValue(newChunk) + + const result = await addChunk('d1', 'new', 'c1') + + expect(apiFetch).toHaveBeenCalledWith('/api/documents/d1/chunks', { + method: 'POST', + body: JSON.stringify({ text: 'new', afterId: 'c1' }), + }) + expect(result).toEqual(newChunk) + }) + + it('addChunk works without afterId', async () => { + const newChunk = { id: 'cnew', docId: 'd1', text: 'new' } + apiFetch.mockResolvedValue(newChunk) + + await addChunk('d1', 'new') + + expect(apiFetch).toHaveBeenCalledWith('/api/documents/d1/chunks', { + method: 'POST', + body: JSON.stringify({ text: 'new', afterId: undefined }), + }) + }) + + it('fetchChunkDiff calls GET /diff with encoded store param', async () => { + const diffs = [{ chunkId: 'c1', status: 'modified', textDiff: 'diff' }] + apiFetch.mockResolvedValue(diffs) + + const result = await fetchChunkDiff('d1', 'my store') + + expect(apiFetch).toHaveBeenCalledWith('/api/documents/d1/diff?store=my%20store') + expect(result).toEqual(diffs) + }) + + it('pushChunksToStore calls POST /chunks/push with store', async () => { + const response = { jobId: 'j1', summary: { embeds: 5, tokens: 1000 } } + apiFetch.mockResolvedValue(response) + + const result = await pushChunksToStore('d1', 'my-store') + + expect(apiFetch).toHaveBeenCalledWith('/api/documents/d1/chunks/push', { + method: 'POST', + body: JSON.stringify({ store: 'my-store' }), + }) + expect(result).toEqual(response) + }) +}) diff --git a/frontend/src/features/chunks/api.ts b/frontend/src/features/chunks/api.ts new file mode 100644 index 0000000..fd69f8e --- /dev/null +++ b/frontend/src/features/chunks/api.ts @@ -0,0 +1,60 @@ +import type { DocChunk, ChunkDiff, PushSummary } from '../../shared/types' +import { apiFetch } from '../../shared/api/http' + +export function fetchChunks(docId: string): Promise { + return apiFetch(`/api/documents/${docId}/chunks`) +} + +export function updateChunk( + docId: string, + chunkId: string, + patch: { text?: string; title?: string }, +): Promise { + return apiFetch(`/api/documents/${docId}/chunks/${chunkId}`, { + method: 'PATCH', + body: JSON.stringify(patch), + }) +} + +export function mergeChunks(docId: string, ids: string[]): Promise { + return apiFetch(`/api/documents/${docId}/chunks/merge`, { + method: 'POST', + body: JSON.stringify({ ids }), + }) +} + +export function splitChunk( + docId: string, + chunkId: string, + cursorOffset: number, +): Promise { + return apiFetch(`/api/documents/${docId}/chunks/${chunkId}/split`, { + method: 'POST', + body: JSON.stringify({ cursorOffset }), + }) +} + +export function dropChunk(docId: string, chunkId: string): Promise { + return apiFetch(`/api/documents/${docId}/chunks/${chunkId}`, { method: 'DELETE' }) +} + +export function addChunk(docId: string, text: string, afterId?: string): Promise { + return apiFetch(`/api/documents/${docId}/chunks`, { + method: 'POST', + body: JSON.stringify({ text, afterId }), + }) +} + +export function fetchChunkDiff(docId: string, store: string): Promise { + return apiFetch(`/api/documents/${docId}/diff?store=${encodeURIComponent(store)}`) +} + +export function pushChunksToStore( + docId: string, + store: string, +): Promise<{ jobId: string; summary: PushSummary }> { + return apiFetch<{ jobId: string; summary: PushSummary }>(`/api/documents/${docId}/chunks/push`, { + method: 'POST', + body: JSON.stringify({ store }), + }) +} diff --git a/frontend/src/features/chunks/index.ts b/frontend/src/features/chunks/index.ts new file mode 100644 index 0000000..661868b --- /dev/null +++ b/frontend/src/features/chunks/index.ts @@ -0,0 +1,2 @@ +export { useChunksStore } from './store' +export * from './api' diff --git a/frontend/src/features/chunks/store.test.ts b/frontend/src/features/chunks/store.test.ts new file mode 100644 index 0000000..fab6645 --- /dev/null +++ b/frontend/src/features/chunks/store.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { useChunksStore } from './store' + +vi.mock('./api', () => ({ + fetchChunks: vi.fn(), + updateChunk: vi.fn(), + mergeChunks: vi.fn(), + splitChunk: vi.fn(), + dropChunk: vi.fn(), + addChunk: vi.fn(), + fetchChunkDiff: vi.fn(), + pushChunksToStore: vi.fn(), +})) + +import * as api from './api' + +const makeChunk = (id: string, text = 'text') => ({ + id, + docId: 'd1', + text, + createdAt: '2025-01-01T00:00:00Z', + updatedAt: '2025-01-01T00:00:00Z', +}) + +describe('useChunksStore', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + it('load — sets chunks and clears loading', async () => { + const chunks = [makeChunk('c1'), makeChunk('c2')] + api.fetchChunks.mockResolvedValue(chunks) + + const store = useChunksStore() + const loadPromise = store.load('d1') + expect(store.loading).toBe(true) + await loadPromise + expect(store.loading).toBe(false) + expect(store.chunks).toEqual(chunks) + }) + + it('load — sets error on failure', async () => { + api.fetchChunks.mockRejectedValue(new Error('boom')) + const store = useChunksStore() + await store.load('d1') + expect(store.error).toBe('boom') + expect(store.chunks).toEqual([]) + }) + + it('updateText — replaces chunk in list', async () => { + const store = useChunksStore() + store.chunks = [makeChunk('c1', 'original')] + const updated = makeChunk('c1', 'changed') + api.updateChunk.mockResolvedValue(updated) + + await store.updateText('d1', 'c1', 'changed') + + expect(store.chunks[0].text).toBe('changed') + }) + + it('updateTitle — replaces chunk in list', async () => { + const store = useChunksStore() + store.chunks = [makeChunk('c1')] + const updated = { ...makeChunk('c1'), title: 'New title' } + api.updateChunk.mockResolvedValue(updated) + + await store.updateTitle('d1', 'c1', 'New title') + + expect(store.chunks[0].title).toBe('New title') + }) + + it('drop — removes chunk from list', async () => { + const store = useChunksStore() + store.chunks = [makeChunk('c1'), makeChunk('c2')] + api.dropChunk.mockResolvedValue(undefined) + + await store.drop('d1', 'c1') + + expect(store.chunks).toHaveLength(1) + expect(store.chunks[0].id).toBe('c2') + }) + + it('add — appends at end when no afterId', async () => { + const store = useChunksStore() + store.chunks = [makeChunk('c1')] + const created = makeChunk('cnew', 'new text') + api.addChunk.mockResolvedValue(created) + + await store.add('d1', 'new text') + + expect(store.chunks).toHaveLength(2) + expect(store.chunks[1].id).toBe('cnew') + }) + + it('add — inserts after given chunk', async () => { + const store = useChunksStore() + store.chunks = [makeChunk('c1'), makeChunk('c2')] + const created = makeChunk('cnew', 'new text') + api.addChunk.mockResolvedValue(created) + + await store.add('d1', 'new text', 'c1') + + expect(store.chunks[1].id).toBe('cnew') + expect(store.chunks[2].id).toBe('c2') + }) + + it('loadDiff — sets diff', async () => { + const diffs = [{ chunkId: 'c1', status: 'modified' as const, textDiff: 'x' }] + api.fetchChunkDiff.mockResolvedValue(diffs) + const store = useChunksStore() + + await store.loadDiff('d1', 'my-store') + + expect(store.diff).toEqual(diffs) + }) + + it('push — returns jobId on success', async () => { + api.pushChunksToStore.mockResolvedValue({ jobId: 'j1', summary: { embeds: 3, tokens: 500 } }) + const store = useChunksStore() + + const jobId = await store.push('d1', 'my-store') + + expect(jobId).toBe('j1') + }) + + it('push — returns null on failure', async () => { + api.pushChunksToStore.mockRejectedValue(new Error('network')) + const store = useChunksStore() + + const jobId = await store.push('d1', 'my-store') + + expect(jobId).toBeNull() + expect(store.error).toBe('network') + }) +}) diff --git a/frontend/src/features/chunks/store.ts b/frontend/src/features/chunks/store.ts new file mode 100644 index 0000000..c77178e --- /dev/null +++ b/frontend/src/features/chunks/store.ts @@ -0,0 +1,175 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import type { DocChunk, ChunkDiff } from '../../shared/types' +import * as api from './api' + +export const useChunksStore = defineStore('chunks', () => { + const chunks = ref([]) + const loading = ref(false) + const saving = ref(false) + const diffing = ref(false) + const diff = ref([]) + const error = ref(null) + + function clearError(): void { + error.value = null + } + + async function load(docId: string): Promise { + loading.value = true + error.value = null + try { + chunks.value = await api.fetchChunks(docId) + } catch (e) { + error.value = (e as Error).message || 'Failed to load chunks' + console.error('Failed to load chunks', e) + } finally { + loading.value = false + } + } + + async function updateText(docId: string, chunkId: string, text: string): Promise { + saving.value = true + try { + const updated = await api.updateChunk(docId, chunkId, { text }) + const idx = chunks.value.findIndex((c) => c.id === chunkId) + if (idx !== -1) chunks.value = chunks.value.with(idx, updated) + } catch (e) { + error.value = (e as Error).message || 'Failed to save chunk' + console.error('Failed to save chunk', e) + } finally { + saving.value = false + } + } + + async function updateTitle(docId: string, chunkId: string, title: string): Promise { + saving.value = true + try { + const updated = await api.updateChunk(docId, chunkId, { title }) + const idx = chunks.value.findIndex((c) => c.id === chunkId) + if (idx !== -1) chunks.value = chunks.value.with(idx, updated) + } catch (e) { + error.value = (e as Error).message || 'Failed to retitle chunk' + console.error('Failed to retitle chunk', e) + } finally { + saving.value = false + } + } + + async function merge(docId: string, ids: string[]): Promise { + saving.value = true + try { + const updated = await api.mergeChunks(docId, ids) + const idSet = new Set(ids) + const kept = chunks.value.filter((c) => !idSet.has(c.id)) + const firstIdx = chunks.value.findIndex((c) => idSet.has(c.id)) + chunks.value = [...kept.slice(0, firstIdx), ...updated, ...kept.slice(firstIdx)] + } catch (e) { + error.value = (e as Error).message || 'Failed to merge chunks' + console.error('Failed to merge chunks', e) + } finally { + saving.value = false + } + } + + async function split(docId: string, chunkId: string, cursorOffset: number): Promise { + saving.value = true + try { + const produced = await api.splitChunk(docId, chunkId, cursorOffset) + const idx = chunks.value.findIndex((c) => c.id === chunkId) + if (idx !== -1) { + chunks.value = [...chunks.value.slice(0, idx), ...produced, ...chunks.value.slice(idx + 1)] + } + } catch (e) { + error.value = (e as Error).message || 'Failed to split chunk' + console.error('Failed to split chunk', e) + } finally { + saving.value = false + } + } + + async function drop(docId: string, chunkId: string): Promise { + saving.value = true + try { + await api.dropChunk(docId, chunkId) + chunks.value = chunks.value.filter((c) => c.id !== chunkId) + } catch (e) { + error.value = (e as Error).message || 'Failed to drop chunk' + console.error('Failed to drop chunk', e) + } finally { + saving.value = false + } + } + + async function add(docId: string, text: string, afterId?: string): Promise { + saving.value = true + try { + const created = await api.addChunk(docId, text, afterId) + if (afterId) { + const idx = chunks.value.findIndex((c) => c.id === afterId) + if (idx !== -1) { + chunks.value = [ + ...chunks.value.slice(0, idx + 1), + created, + ...chunks.value.slice(idx + 1), + ] + return + } + } + chunks.value = [...chunks.value, created] + } catch (e) { + error.value = (e as Error).message || 'Failed to add chunk' + console.error('Failed to add chunk', e) + } finally { + saving.value = false + } + } + + async function loadDiff(docId: string, store: string): Promise { + diffing.value = true + error.value = null + try { + diff.value = await api.fetchChunkDiff(docId, store) + } catch (e) { + error.value = (e as Error).message || 'Failed to load diff' + console.error('Failed to load diff', e) + } finally { + diffing.value = false + } + } + + function clearDiff(): void { + diff.value = [] + } + + async function push(docId: string, store: string): Promise { + try { + const res = await api.pushChunksToStore(docId, store) + return res.jobId + } catch (e) { + error.value = (e as Error).message || 'Failed to push chunks' + console.error('Failed to push chunks', e) + return null + } + } + + return { + chunks, + loading, + saving, + diffing, + diff, + error, + clearError, + load, + updateText, + updateTitle, + merge, + split, + drop, + add, + loadDiff, + clearDiff, + push, + } +}) diff --git a/frontend/src/features/chunks/ui/ChunkItem.vue b/frontend/src/features/chunks/ui/ChunkItem.vue new file mode 100644 index 0000000..83e48dc --- /dev/null +++ b/frontend/src/features/chunks/ui/ChunkItem.vue @@ -0,0 +1,382 @@ +