feat(#216): E4/E5 — Doc workspace shell, DocTreeRail, DocWorkspaceHeader, editable chunks editor
Closes #216 Closes #217 Closes #218 Closes #219 Closes #220 Closes #221 Closes #222
This commit is contained in:
parent
59af8acdc6
commit
cab3e53fe4
18 changed files with 2450 additions and 22 deletions
131
frontend/src/features/chunks/api.test.ts
Normal file
131
frontend/src/features/chunks/api.test.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
60
frontend/src/features/chunks/api.ts
Normal file
60
frontend/src/features/chunks/api.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import type { DocChunk, ChunkDiff, PushSummary } from '../../shared/types'
|
||||
import { apiFetch } from '../../shared/api/http'
|
||||
|
||||
export function fetchChunks(docId: string): Promise<DocChunk[]> {
|
||||
return apiFetch<DocChunk[]>(`/api/documents/${docId}/chunks`)
|
||||
}
|
||||
|
||||
export function updateChunk(
|
||||
docId: string,
|
||||
chunkId: string,
|
||||
patch: { text?: string; title?: string },
|
||||
): Promise<DocChunk> {
|
||||
return apiFetch<DocChunk>(`/api/documents/${docId}/chunks/${chunkId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(patch),
|
||||
})
|
||||
}
|
||||
|
||||
export function mergeChunks(docId: string, ids: string[]): Promise<DocChunk[]> {
|
||||
return apiFetch<DocChunk[]>(`/api/documents/${docId}/chunks/merge`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ids }),
|
||||
})
|
||||
}
|
||||
|
||||
export function splitChunk(
|
||||
docId: string,
|
||||
chunkId: string,
|
||||
cursorOffset: number,
|
||||
): Promise<DocChunk[]> {
|
||||
return apiFetch<DocChunk[]>(`/api/documents/${docId}/chunks/${chunkId}/split`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ cursorOffset }),
|
||||
})
|
||||
}
|
||||
|
||||
export function dropChunk(docId: string, chunkId: string): Promise<void> {
|
||||
return apiFetch(`/api/documents/${docId}/chunks/${chunkId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function addChunk(docId: string, text: string, afterId?: string): Promise<DocChunk> {
|
||||
return apiFetch<DocChunk>(`/api/documents/${docId}/chunks`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ text, afterId }),
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchChunkDiff(docId: string, store: string): Promise<ChunkDiff[]> {
|
||||
return apiFetch<ChunkDiff[]>(`/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 }),
|
||||
})
|
||||
}
|
||||
2
frontend/src/features/chunks/index.ts
Normal file
2
frontend/src/features/chunks/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { useChunksStore } from './store'
|
||||
export * from './api'
|
||||
137
frontend/src/features/chunks/store.test.ts
Normal file
137
frontend/src/features/chunks/store.test.ts
Normal file
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
175
frontend/src/features/chunks/store.ts
Normal file
175
frontend/src/features/chunks/store.ts
Normal file
|
|
@ -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<DocChunk[]>([])
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const diffing = ref(false)
|
||||
const diff = ref<ChunkDiff[]>([])
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
function clearError(): void {
|
||||
error.value = null
|
||||
}
|
||||
|
||||
async function load(docId: string): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string | null> {
|
||||
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,
|
||||
}
|
||||
})
|
||||
382
frontend/src/features/chunks/ui/ChunkItem.vue
Normal file
382
frontend/src/features/chunks/ui/ChunkItem.vue
Normal file
|
|
@ -0,0 +1,382 @@
|
|||
<template>
|
||||
<div
|
||||
class="chunk-item"
|
||||
:class="[
|
||||
`chunk-item--${diffStatus}`,
|
||||
{ 'chunk-item--selected': selected, 'chunk-item--editing': editing },
|
||||
]"
|
||||
data-e2e="chunk-item"
|
||||
@click="$emit('selectChunk', chunk.id)"
|
||||
>
|
||||
<!-- Selection checkbox -->
|
||||
<input
|
||||
type="checkbox"
|
||||
class="chunk-select"
|
||||
:checked="selected"
|
||||
@change.stop="$emit('toggleSelect', chunk.id)"
|
||||
@click.stop
|
||||
/>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="chunk-body">
|
||||
<!-- Title row -->
|
||||
<div class="chunk-title-row">
|
||||
<span
|
||||
v-if="!editingTitle"
|
||||
class="chunk-title"
|
||||
:class="{ 'chunk-title--empty': !chunk.title }"
|
||||
@dblclick.stop="startEditTitle"
|
||||
>
|
||||
{{ chunk.title || t('chunks.noTitle') }}
|
||||
</span>
|
||||
<input
|
||||
v-else
|
||||
ref="titleInput"
|
||||
v-model="draftTitle"
|
||||
class="chunk-title-input"
|
||||
@blur="commitTitle"
|
||||
@keydown.enter.prevent="commitTitle"
|
||||
@keydown.escape.prevent="cancelTitle"
|
||||
@click.stop
|
||||
/>
|
||||
<span class="chunk-meta">
|
||||
<span v-if="chunk.pageRange">p.{{ chunk.pageRange[0] }}–{{ chunk.pageRange[1] }}</span>
|
||||
<span v-if="chunk.tokenCount">{{ chunk.tokenCount }}t</span>
|
||||
<span v-if="savingThis" class="saving-indicator">{{ t('chunks.saving') }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Text -->
|
||||
<textarea
|
||||
v-if="editing"
|
||||
ref="textArea"
|
||||
v-model="draftText"
|
||||
class="chunk-text-edit"
|
||||
:placeholder="t('chunks.editPlaceholder')"
|
||||
rows="4"
|
||||
@blur="commitText"
|
||||
@click.stop
|
||||
@keydown.escape.prevent="cancelText"
|
||||
/>
|
||||
<p
|
||||
v-else
|
||||
class="chunk-text"
|
||||
:class="{ 'chunk-text--removed': diffStatus === 'removed' }"
|
||||
@dblclick.stop="startEdit"
|
||||
>
|
||||
{{ chunk.text }}
|
||||
</p>
|
||||
|
||||
<!-- Diff inline text diff -->
|
||||
<div v-if="diffStatus === 'modified' && chunk.id && diffEntry?.textDiff" class="chunk-diff">
|
||||
<code class="diff-text">{{ diffEntry.textDiff }}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions menu -->
|
||||
<div class="chunk-actions" @click.stop>
|
||||
<button
|
||||
class="chunk-action-btn"
|
||||
:title="t('chunks.mergePrev')"
|
||||
:disabled="isFirst"
|
||||
@click="$emit('mergePrev', chunk.id)"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
class="chunk-action-btn"
|
||||
:title="t('chunks.mergeNext')"
|
||||
:disabled="isLast"
|
||||
@click="$emit('mergeNext', chunk.id)"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<button class="chunk-action-btn" :title="t('chunks.split')" @click="splitAtCurrentCursor">
|
||||
⎀
|
||||
</button>
|
||||
<button class="chunk-action-btn" :title="t('chunks.add')" @click="$emit('add', chunk.id)">
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
class="chunk-action-btn chunk-action-btn--danger"
|
||||
:title="t('chunks.drop')"
|
||||
@click="$emit('drop', chunk.id)"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick } from 'vue'
|
||||
import type { DocChunk, ChunkDiff, ChunkDiffStatus } from '../../../shared/types'
|
||||
import { useI18n } from '../../../shared/i18n'
|
||||
|
||||
const props = defineProps<{
|
||||
chunk: DocChunk
|
||||
selected?: boolean
|
||||
savingThis?: boolean
|
||||
isFirst?: boolean
|
||||
isLast?: boolean
|
||||
diffEntry?: ChunkDiff | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectChunk: [id: string]
|
||||
toggleSelect: [id: string]
|
||||
textChange: [id: string, text: string]
|
||||
titleChange: [id: string, title: string]
|
||||
mergePrev: [id: string]
|
||||
mergeNext: [id: string]
|
||||
split: [id: string, cursorOffset: number]
|
||||
drop: [id: string]
|
||||
add: [afterId: string]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const editing = ref(false)
|
||||
const editingTitle = ref(false)
|
||||
const draftText = ref('')
|
||||
const draftTitle = ref('')
|
||||
const textArea = ref<HTMLTextAreaElement | null>(null)
|
||||
const titleInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const diffStatus = computed<ChunkDiffStatus>(() => props.diffEntry?.status ?? 'unchanged')
|
||||
|
||||
function startEdit(): void {
|
||||
draftText.value = props.chunk.text
|
||||
editing.value = true
|
||||
nextTick(() => textArea.value?.focus())
|
||||
}
|
||||
|
||||
function commitText(): void {
|
||||
if (draftText.value !== props.chunk.text) {
|
||||
emit('textChange', props.chunk.id, draftText.value)
|
||||
}
|
||||
editing.value = false
|
||||
}
|
||||
|
||||
function cancelText(): void {
|
||||
editing.value = false
|
||||
}
|
||||
|
||||
function startEditTitle(): void {
|
||||
draftTitle.value = props.chunk.title ?? ''
|
||||
editingTitle.value = true
|
||||
nextTick(() => titleInput.value?.focus())
|
||||
}
|
||||
|
||||
function commitTitle(): void {
|
||||
if (draftTitle.value !== (props.chunk.title ?? '')) {
|
||||
emit('titleChange', props.chunk.id, draftTitle.value)
|
||||
}
|
||||
editingTitle.value = false
|
||||
}
|
||||
|
||||
function cancelTitle(): void {
|
||||
editingTitle.value = false
|
||||
}
|
||||
|
||||
function splitAtCurrentCursor(): void {
|
||||
if (!textArea.value) {
|
||||
emit('split', props.chunk.id, Math.floor(props.chunk.text.length / 2))
|
||||
return
|
||||
}
|
||||
emit('split', props.chunk.id, textArea.value.selectionStart)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chunk-item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-elevated);
|
||||
transition:
|
||||
border-color var(--transition),
|
||||
background var(--transition);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.chunk-item:hover {
|
||||
border-color: var(--border-hover, var(--border));
|
||||
}
|
||||
|
||||
.chunk-item--selected {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-muted);
|
||||
}
|
||||
|
||||
.chunk-item--added {
|
||||
border-color: var(--success);
|
||||
background: color-mix(in srgb, var(--success) 8%, transparent);
|
||||
}
|
||||
|
||||
.chunk-item--modified {
|
||||
border-color: var(--warning);
|
||||
background: color-mix(in srgb, var(--warning) 8%, transparent);
|
||||
}
|
||||
|
||||
.chunk-item--removed {
|
||||
border-color: var(--error);
|
||||
background: color-mix(in srgb, var(--error) 8%, transparent);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.chunk-select {
|
||||
margin-top: 2px;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.chunk-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chunk-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chunk-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.chunk-title--empty {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.chunk-title-input {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 1px 6px;
|
||||
color: var(--text);
|
||||
flex: 1;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.chunk-meta {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.saving-indicator {
|
||||
color: var(--accent);
|
||||
animation: blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
.chunk-text {
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
cursor: text;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.chunk-text--removed {
|
||||
text-decoration: line-through;
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.chunk-text-edit {
|
||||
width: 100%;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
color: var(--text);
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 8px;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.chunk-diff {
|
||||
padding: 4px 8px;
|
||||
background: var(--bg-surface);
|
||||
border-radius: var(--radius-sm);
|
||||
border-left: 3px solid var(--warning);
|
||||
}
|
||||
|
||||
.diff-text {
|
||||
font-size: 11px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
color: var(--text-secondary);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.chunk-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition);
|
||||
}
|
||||
|
||||
.chunk-item:hover .chunk-actions,
|
||||
.chunk-item--selected .chunk-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.chunk-action-btn {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.chunk-action-btn:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.chunk-action-btn:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.chunk-action-btn--danger:hover:not(:disabled) {
|
||||
border-color: var(--error);
|
||||
color: var(--error);
|
||||
}
|
||||
</style>
|
||||
618
frontend/src/features/chunks/ui/ChunksEditor.vue
Normal file
618
frontend/src/features/chunks/ui/ChunksEditor.vue
Normal file
|
|
@ -0,0 +1,618 @@
|
|||
<template>
|
||||
<div class="chunks-editor" data-e2e="chunks-editor">
|
||||
<!-- Toolbar -->
|
||||
<div class="chunks-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<span class="chunk-count">{{ t('chunks.count', { n: chunksStore.chunks.length }) }}</span>
|
||||
<span v-if="chunksStore.saving" class="toolbar-saving">{{ t('chunks.saving') }}</span>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<!-- Diff toggle (#221) -->
|
||||
<button
|
||||
class="toolbar-btn"
|
||||
:class="{ active: showDiff }"
|
||||
:disabled="!availableStores.length"
|
||||
@click="toggleDiff"
|
||||
>
|
||||
{{ showDiff ? t('chunks.diffClose') : t('chunks.diffToggle') }}
|
||||
</button>
|
||||
<!-- Push to store (#222) -->
|
||||
<button class="toolbar-btn toolbar-btn--primary" @click="openPushModal">
|
||||
{{ t('chunks.pushTitle') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Diff store selector -->
|
||||
<div v-if="showDiff" class="diff-bar">
|
||||
<label class="diff-label">{{ t('chunks.diffRef') }}</label>
|
||||
<select v-model="diffStore" class="diff-store-select" @change="reloadDiff">
|
||||
<option v-for="s in availableStores" :key="s" :value="s">{{ s }}</option>
|
||||
</select>
|
||||
<span v-if="chunksStore.diffing" class="diff-loading">…</span>
|
||||
<span v-else class="diff-counts">
|
||||
<span class="diff-added">+{{ diffSummary.added }}</span>
|
||||
<span class="diff-modified">~{{ diffSummary.modified }}</span>
|
||||
<span class="diff-removed">-{{ diffSummary.removed }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Bulk selection bar -->
|
||||
<div v-if="selectedIds.size > 0" class="bulk-bar" data-e2e="bulk-bar">
|
||||
<span class="bulk-count">{{ t('chunks.selected', { n: selectedIds.size }) }}</span>
|
||||
<button class="bulk-btn" @click="bulkDrop">{{ t('chunks.bulkDrop') }}</button>
|
||||
<button class="bulk-btn" :disabled="selectedIds.size < 2" @click="bulkMerge">
|
||||
{{ t('chunks.bulkMerge') }}
|
||||
</button>
|
||||
<button class="bulk-btn bulk-btn--cancel" @click="selectedIds = new Set()">
|
||||
{{ t('chunks.bulkCancel') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div
|
||||
v-if="!chunksStore.loading && !chunksStore.chunks.length"
|
||||
class="chunks-empty"
|
||||
data-e2e="chunks-empty"
|
||||
>
|
||||
<p>{{ t('chunks.empty') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-else-if="chunksStore.loading" class="chunks-loading">
|
||||
<span class="spinner" />
|
||||
</div>
|
||||
|
||||
<!-- Chunk list -->
|
||||
<div v-else class="chunk-list" data-e2e="chunk-list">
|
||||
<ChunkItem
|
||||
v-for="(chunk, idx) in chunksStore.chunks"
|
||||
:key="chunk.id"
|
||||
:chunk="chunk"
|
||||
:selected="selectedIds.has(chunk.id)"
|
||||
:saving-this="savingId === chunk.id"
|
||||
:is-first="idx === 0"
|
||||
:is-last="idx === chunksStore.chunks.length - 1"
|
||||
:diff-entry="diffMap.get(chunk.id) ?? null"
|
||||
@select-chunk="onSelectChunk"
|
||||
@toggle-select="onToggleSelect"
|
||||
@text-change="onTextChange"
|
||||
@title-change="onTitleChange"
|
||||
@merge-prev="(id) => onMerge(id, 'prev')"
|
||||
@merge-next="(id) => onMerge(id, 'next')"
|
||||
@split="onSplit"
|
||||
@drop="onDrop"
|
||||
@add="onAdd"
|
||||
/>
|
||||
<!-- Add chunk at end -->
|
||||
<button class="add-chunk-end" @click="onAdd(undefined)">+ {{ t('chunks.add') }}</button>
|
||||
</div>
|
||||
|
||||
<!-- Push to store modal (#222) -->
|
||||
<div v-if="pushModalOpen" class="modal-backdrop" @click.self="closePushModal">
|
||||
<div class="modal" role="dialog" :aria-label="t('chunks.pushTitle')" aria-modal="true">
|
||||
<h2 class="modal-title">{{ t('chunks.pushTitle') }}</h2>
|
||||
<label class="modal-label">{{ t('chunks.pushStore') }}</label>
|
||||
<input
|
||||
ref="pushInput"
|
||||
v-model="pushStoreName"
|
||||
class="modal-input"
|
||||
list="push-store-datalist"
|
||||
:placeholder="t('chunks.pushPlaceholder')"
|
||||
@keydown.enter.prevent="confirmPush"
|
||||
@keydown.escape.prevent="closePushModal"
|
||||
/>
|
||||
<datalist id="push-store-datalist">
|
||||
<option v-for="s in availableStores" :key="s" :value="s" />
|
||||
</datalist>
|
||||
<div v-if="pushSummary" class="push-summary">
|
||||
{{ t('chunks.pushSummary', { embeds: pushSummary.embeds, tokens: pushSummary.tokens }) }}
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="modal-btn modal-btn--cancel" @click="closePushModal">
|
||||
{{ t('chunks.pushCancel') }}
|
||||
</button>
|
||||
<button
|
||||
class="modal-btn modal-btn--primary"
|
||||
:disabled="!pushStoreName.trim() || pushing"
|
||||
@click="confirmPush"
|
||||
>
|
||||
{{ pushing ? '…' : t('chunks.pushConfirm') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, nextTick, onMounted } from 'vue'
|
||||
import { useChunksStore } from '../store'
|
||||
import { useI18n } from '../../../shared/i18n'
|
||||
import type { ChunkDiff } from '../../../shared/types'
|
||||
import ChunkItem from './ChunkItem.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
docId: string
|
||||
availableStores: string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
nodeHighlight: [ref: string | null]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const chunksStore = useChunksStore()
|
||||
|
||||
const selectedIds = ref<Set<string>>(new Set())
|
||||
const savingId = ref<string | null>(null)
|
||||
|
||||
// --- Diff state (#221) ---
|
||||
const showDiff = ref(false)
|
||||
const diffStore = ref(props.availableStores[0] ?? '')
|
||||
|
||||
const diffMap = computed<Map<string, ChunkDiff>>(() => {
|
||||
const m = new Map<string, ChunkDiff>()
|
||||
if (!showDiff.value) return m
|
||||
for (const d of chunksStore.diff) m.set(d.chunkId, d)
|
||||
return m
|
||||
})
|
||||
|
||||
const diffSummary = computed(() => {
|
||||
const counts = { added: 0, modified: 0, removed: 0 }
|
||||
for (const d of chunksStore.diff) {
|
||||
if (d.status === 'added') counts.added++
|
||||
else if (d.status === 'modified') counts.modified++
|
||||
else if (d.status === 'removed') counts.removed++
|
||||
}
|
||||
return counts
|
||||
})
|
||||
|
||||
async function toggleDiff(): Promise<void> {
|
||||
if (showDiff.value) {
|
||||
showDiff.value = false
|
||||
chunksStore.clearDiff()
|
||||
return
|
||||
}
|
||||
if (!diffStore.value) diffStore.value = props.availableStores[0] ?? ''
|
||||
showDiff.value = true
|
||||
await chunksStore.loadDiff(props.docId, diffStore.value)
|
||||
}
|
||||
|
||||
async function reloadDiff(): Promise<void> {
|
||||
if (showDiff.value && diffStore.value) {
|
||||
await chunksStore.loadDiff(props.docId, diffStore.value)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Push to store (#222) ---
|
||||
const pushModalOpen = ref(false)
|
||||
const pushStoreName = ref('')
|
||||
const pushing = ref(false)
|
||||
const pushSummary = ref<{ embeds: number; tokens: number } | null>(null)
|
||||
const pushInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
function openPushModal(): void {
|
||||
pushStoreName.value = props.availableStores[0] ?? ''
|
||||
pushSummary.value = null
|
||||
pushModalOpen.value = true
|
||||
nextTick(() => pushInput.value?.focus())
|
||||
}
|
||||
|
||||
function closePushModal(): void {
|
||||
pushModalOpen.value = false
|
||||
pushing.value = false
|
||||
}
|
||||
|
||||
async function confirmPush(): Promise<void> {
|
||||
const store = pushStoreName.value.trim()
|
||||
if (!store || pushing.value) return
|
||||
pushing.value = true
|
||||
const jobId = await chunksStore.push(props.docId, store)
|
||||
pushing.value = false
|
||||
if (jobId) {
|
||||
closePushModal()
|
||||
alert(t('chunks.pushedJob', { jobId }))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Chunk interactions (#219 / #220) ---
|
||||
function onSelectChunk(id: string): void {
|
||||
const chunk = chunksStore.chunks.find((c) => c.id === id)
|
||||
emit('nodeHighlight', chunk?.sourceNodeRef ?? null)
|
||||
}
|
||||
|
||||
function onToggleSelect(id: string): void {
|
||||
const next = new Set(selectedIds.value)
|
||||
if (next.has(id)) {
|
||||
next.delete(id)
|
||||
} else {
|
||||
next.add(id)
|
||||
}
|
||||
selectedIds.value = next
|
||||
}
|
||||
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function onTextChange(id: string, text: string): void {
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
savingId.value = id
|
||||
saveTimer = setTimeout(async () => {
|
||||
await chunksStore.updateText(props.docId, id, text)
|
||||
savingId.value = null
|
||||
saveTimer = null
|
||||
}, 600)
|
||||
}
|
||||
|
||||
async function onTitleChange(id: string, title: string): Promise<void> {
|
||||
await chunksStore.updateTitle(props.docId, id, title)
|
||||
}
|
||||
|
||||
async function onMerge(id: string, dir: 'prev' | 'next'): Promise<void> {
|
||||
const idx = chunksStore.chunks.findIndex((c) => c.id === id)
|
||||
if (idx === -1) return
|
||||
const other = dir === 'prev' ? chunksStore.chunks[idx - 1] : chunksStore.chunks[idx + 1]
|
||||
if (!other) return
|
||||
const ids = dir === 'prev' ? [other.id, id] : [id, other.id]
|
||||
await chunksStore.merge(props.docId, ids)
|
||||
}
|
||||
|
||||
async function onSplit(id: string, cursorOffset: number): Promise<void> {
|
||||
await chunksStore.split(props.docId, id, cursorOffset)
|
||||
}
|
||||
|
||||
async function onDrop(id: string): Promise<void> {
|
||||
await chunksStore.drop(props.docId, id)
|
||||
const next = new Set(selectedIds.value)
|
||||
next.delete(id)
|
||||
selectedIds.value = next
|
||||
}
|
||||
|
||||
async function onAdd(afterId: string | undefined): Promise<void> {
|
||||
await chunksStore.add(props.docId, '', afterId)
|
||||
}
|
||||
|
||||
// --- Bulk actions (#220) ---
|
||||
async function bulkDrop(): Promise<void> {
|
||||
const ids = [...selectedIds.value]
|
||||
for (const id of ids) await chunksStore.drop(props.docId, id)
|
||||
selectedIds.value = new Set()
|
||||
}
|
||||
|
||||
async function bulkMerge(): Promise<void> {
|
||||
const ids = [...selectedIds.value]
|
||||
if (ids.length < 2) return
|
||||
const ordered = chunksStore.chunks.filter((c) => selectedIds.value.has(c.id)).map((c) => c.id)
|
||||
await chunksStore.merge(props.docId, ordered)
|
||||
selectedIds.value = new Set()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
chunksStore.load(props.docId)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chunks-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chunks-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-surface);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toolbar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chunk-count {
|
||||
font-size: 12px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.toolbar-saving {
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.toolbar-btn:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.toolbar-btn.active {
|
||||
background: var(--accent-muted);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.toolbar-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.toolbar-btn--primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toolbar-btn--primary:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
border-color: var(--accent-hover);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Diff bar */
|
||||
.diff-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 16px;
|
||||
background: color-mix(in srgb, var(--warning) 5%, var(--bg-surface));
|
||||
border-bottom: 1px solid var(--warning);
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.diff-label {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.diff-store-select {
|
||||
font-size: 12px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px 6px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.diff-loading {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.diff-counts {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.diff-added {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.diff-modified {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.diff-removed {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
/* Bulk bar */
|
||||
.bulk-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px;
|
||||
background: var(--accent-muted);
|
||||
border-bottom: 1px solid var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bulk-count {
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bulk-btn {
|
||||
padding: 3px 10px;
|
||||
font-size: 12px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.bulk-btn:hover:not(:disabled) {
|
||||
border-color: var(--error);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.bulk-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.bulk-btn--cancel:hover {
|
||||
border-color: var(--border);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* States */
|
||||
.chunks-empty,
|
||||
.chunks-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* List */
|
||||
.chunk-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.add-chunk-end {
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
background: none;
|
||||
border: 1px dashed var(--border-light);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.add-chunk-end:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Push modal */
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px;
|
||||
width: 380px;
|
||||
max-width: 90vw;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.modal-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.modal-input {
|
||||
width: 100%;
|
||||
font-size: 13px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 7px 10px;
|
||||
color: var(--text);
|
||||
outline: none;
|
||||
transition: border-color var(--transition);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.modal-input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.push-summary {
|
||||
font-size: 12px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-elevated);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.modal-btn {
|
||||
padding: 6px 14px;
|
||||
font-size: 13px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.modal-btn--cancel {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.modal-btn--cancel:hover {
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
.modal-btn--primary {
|
||||
background: var(--accent);
|
||||
border: 1px solid var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modal-btn--primary:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.modal-btn--primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid var(--border-light);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -7,6 +7,7 @@ import {
|
|||
getPreviewUrl,
|
||||
rechunkDocument,
|
||||
pushDocumentToStore,
|
||||
fetchDocumentTree,
|
||||
} from './api'
|
||||
|
||||
vi.mock('../../shared/api/http', () => ({
|
||||
|
|
@ -91,4 +92,14 @@ describe('document API', () => {
|
|||
})
|
||||
expect(result).toEqual({ jobId: 'job-2' })
|
||||
})
|
||||
|
||||
it('fetchDocumentTree calls GET /api/documents/:id/tree', async () => {
|
||||
const tree = [{ ref: '#/body', type: 'body', label: 'Body', children: [] }]
|
||||
apiFetch.mockResolvedValue(tree)
|
||||
|
||||
const result = await fetchDocumentTree('42')
|
||||
|
||||
expect(apiFetch).toHaveBeenCalledWith('/api/documents/42/tree')
|
||||
expect(result).toEqual(tree)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { Document } from '../../shared/types'
|
||||
import type { Document, DocTreeNode } from '../../shared/types'
|
||||
import { apiFetch } from '../../shared/api/http'
|
||||
|
||||
export function fetchDocuments(): Promise<Document[]> {
|
||||
|
|
@ -37,3 +37,7 @@ export function pushDocumentToStore(id: string, store: string): Promise<{ jobId:
|
|||
body: JSON.stringify({ store }),
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchDocumentTree(id: string): Promise<DocTreeNode[]> {
|
||||
return apiFetch<DocTreeNode[]>(`/api/documents/${id}/tree`)
|
||||
}
|
||||
|
|
|
|||
139
frontend/src/features/document/ui/DocTreeNode.vue
Normal file
139
frontend/src/features/document/ui/DocTreeNode.vue
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
<template>
|
||||
<li class="tree-node" role="treeitem" :aria-expanded="hasChildren ? open : undefined">
|
||||
<button
|
||||
class="tree-node-row"
|
||||
:class="{
|
||||
'tree-node-row--selected': selected === node.ref,
|
||||
'tree-node-row--highlight': highlight === node.ref,
|
||||
}"
|
||||
@click="onRowClick"
|
||||
>
|
||||
<span class="tree-node-indent" :style="{ width: `${depth * 12}px` }" />
|
||||
<span v-if="hasChildren" class="tree-node-toggle" :class="{ open }" @click.stop="open = !open"
|
||||
>›</span
|
||||
>
|
||||
<span v-else class="tree-node-toggle-placeholder" />
|
||||
<span class="tree-node-type">{{ node.type }}</span>
|
||||
<span class="tree-node-label" :title="node.label">{{ node.label }}</span>
|
||||
</button>
|
||||
<ul v-if="hasChildren && open" class="tree-list" role="group">
|
||||
<DocTreeNode
|
||||
v-for="child in node.children"
|
||||
:key="child.ref"
|
||||
:node="child"
|
||||
:depth="depth + 1"
|
||||
:selected="selected"
|
||||
:highlight="highlight"
|
||||
@select="$emit('select', $event)"
|
||||
/>
|
||||
</ul>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import type { DocTreeNode } from '../../../shared/types'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
node: DocTreeNode
|
||||
depth?: number
|
||||
selected?: string | null
|
||||
highlight?: string | null
|
||||
}>(),
|
||||
{ depth: 0 },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [ref: string]
|
||||
}>()
|
||||
|
||||
const open = ref(props.depth < 2)
|
||||
|
||||
const hasChildren = computed(() => props.node.children.length > 0)
|
||||
|
||||
function onRowClick(): void {
|
||||
emit('select', props.node.ref)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tree-node {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.tree-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.tree-node-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
padding: 3px 8px 3px 4px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
text-align: left;
|
||||
border-radius: 0;
|
||||
transition: background var(--transition);
|
||||
}
|
||||
|
||||
.tree-node-row:hover {
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tree-node-row--selected {
|
||||
background: var(--accent-muted);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.tree-node-row--highlight {
|
||||
background: var(--warning-muted, rgba(234, 179, 8, 0.1));
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tree-node-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
transform: rotate(0deg);
|
||||
transition: transform 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tree-node-toggle.open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.tree-node-toggle-placeholder {
|
||||
width: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tree-node-type {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
padding: 0 4px;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tree-node-label {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
107
frontend/src/features/document/ui/DocTreeRail.vue
Normal file
107
frontend/src/features/document/ui/DocTreeRail.vue
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
<template>
|
||||
<div class="tree-rail" data-e2e="tree-rail">
|
||||
<div v-if="loading" class="tree-rail-placeholder">
|
||||
<span class="spinner" />
|
||||
</div>
|
||||
<div v-else-if="error" class="tree-rail-placeholder tree-rail-error">
|
||||
<span>{{ error }}</span>
|
||||
<button class="retry-btn" @click="$emit('reload')">↺</button>
|
||||
</div>
|
||||
<div v-else-if="!nodes.length" class="tree-rail-placeholder">
|
||||
<span class="tree-rail-empty">{{ t('tree.empty') }}</span>
|
||||
</div>
|
||||
<ul v-else class="tree-list" role="tree">
|
||||
<TreeNode
|
||||
v-for="node in nodes"
|
||||
:key="node.ref"
|
||||
:node="node"
|
||||
:selected="selected"
|
||||
:highlight="highlight"
|
||||
@select="(ref) => $emit('select', ref)"
|
||||
/>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { DocTreeNode } from '../../../shared/types'
|
||||
import { useI18n } from '../../../shared/i18n'
|
||||
import TreeNode from './DocTreeNode.vue'
|
||||
|
||||
defineProps<{
|
||||
nodes: DocTreeNode[]
|
||||
loading?: boolean
|
||||
error?: string | null
|
||||
selected?: string | null
|
||||
highlight?: string | null
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
select: [ref: string]
|
||||
reload: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tree-rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
background: var(--bg-surface);
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.tree-rail-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tree-rail-error {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.tree-list {
|
||||
list-style: none;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--border-light);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.tree-rail-empty {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
123
frontend/src/features/document/ui/DocWorkspaceHeader.vue
Normal file
123
frontend/src/features/document/ui/DocWorkspaceHeader.vue
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
<template>
|
||||
<header class="workspace-header" data-e2e="workspace-header">
|
||||
<div class="workspace-header-main">
|
||||
<svg class="doc-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<h1 class="workspace-title" :title="doc.filename">{{ doc.filename }}</h1>
|
||||
<StatusBadge :state="doc.lifecycleState" />
|
||||
</div>
|
||||
<div class="workspace-header-meta">
|
||||
<div v-if="doc.stores?.length" class="workspace-stores">
|
||||
<RouterLink
|
||||
v-for="store in doc.stores"
|
||||
:key="store"
|
||||
:to="{ name: ROUTES.STORE_DETAIL, params: { store } }"
|
||||
class="store-chip"
|
||||
:title="store"
|
||||
>
|
||||
{{ store }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
<span v-if="doc.fileSize" class="meta-item">{{ formatSize(doc.fileSize) }}</span>
|
||||
<span class="meta-item">{{ formatRelativeTime(doc.lifecycleStateAt ?? doc.createdAt) }}</span>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RouterLink } from 'vue-router'
|
||||
import type { Document } from '../../../shared/types'
|
||||
import { formatSize, formatRelativeTime } from '../../../shared/format'
|
||||
import { ROUTES } from '../../../shared/routing/names'
|
||||
import StatusBadge from './StatusBadge.vue'
|
||||
|
||||
defineProps<{
|
||||
doc: Document
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.workspace-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 12px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.workspace-header-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.doc-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.workspace-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.workspace-header-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.workspace-stores {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.store-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 8px;
|
||||
font-size: 11px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 100px;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
transition: all var(--transition);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
max-width: 120px;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.store-chip:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
}
|
||||
</style>
|
||||
44
frontend/src/pages/DocAskTab.vue
Normal file
44
frontend/src/pages/DocAskTab.vue
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<template>
|
||||
<div class="ask-tab" data-e2e="ask-tab">
|
||||
<div class="coming-soon">
|
||||
<p class="coming-soon-title">{{ t('workspace.askComingSoon') }}</p>
|
||||
<p class="coming-soon-sub">{{ t('workspace.askComingSoonHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from '../shared/i18n'
|
||||
|
||||
defineProps<{ docId: string }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ask-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.coming-soon {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.coming-soon-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.coming-soon-sub {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
132
frontend/src/pages/DocChunksTab.vue
Normal file
132
frontend/src/pages/DocChunksTab.vue
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
<template>
|
||||
<div class="chunks-tab" data-e2e="chunks-tab">
|
||||
<!-- Tree rail -->
|
||||
<div class="tree-pane" :class="{ 'tree-pane--collapsed': treeCollapsed }">
|
||||
<button
|
||||
class="tree-toggle"
|
||||
:title="treeCollapsed ? t('tree.expand') : t('tree.collapse')"
|
||||
@click="treeCollapsed = !treeCollapsed"
|
||||
>
|
||||
<span class="tree-toggle-icon" :class="{ 'tree-toggle-icon--collapsed': treeCollapsed }"
|
||||
>‹</span
|
||||
>
|
||||
</button>
|
||||
<DocTreeRail
|
||||
v-if="!treeCollapsed"
|
||||
:nodes="treeNodes"
|
||||
:loading="treeLoading"
|
||||
:error="treeError"
|
||||
:selected="selectedNodeRef"
|
||||
:highlight="highlightRef"
|
||||
@select="onTreeSelect"
|
||||
@reload="loadTree"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Chunks editor -->
|
||||
<div class="editor-pane">
|
||||
<ChunksEditor
|
||||
:doc-id="docId"
|
||||
:available-stores="availableStores"
|
||||
@node-highlight="highlightRef = $event"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import type { DocTreeNode } from '../shared/types'
|
||||
import { fetchDocumentTree } from '../features/document/api'
|
||||
import DocTreeRail from '../features/document/ui/DocTreeRail.vue'
|
||||
import ChunksEditor from '../features/chunks/ui/ChunksEditor.vue'
|
||||
import { useI18n } from '../shared/i18n'
|
||||
|
||||
const props = defineProps<{
|
||||
docId: string
|
||||
availableStores: string[]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const treeNodes = ref<DocTreeNode[]>([])
|
||||
const treeLoading = ref(false)
|
||||
const treeError = ref<string | null>(null)
|
||||
const treeCollapsed = ref(false)
|
||||
const selectedNodeRef = ref<string | null>(null)
|
||||
const highlightRef = ref<string | null>(null)
|
||||
|
||||
async function loadTree(): Promise<void> {
|
||||
treeLoading.value = true
|
||||
treeError.value = null
|
||||
try {
|
||||
treeNodes.value = await fetchDocumentTree(props.docId)
|
||||
} catch (e) {
|
||||
treeError.value = (e as Error).message || 'Failed to load tree'
|
||||
} finally {
|
||||
treeLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onTreeSelect(ref: string): void {
|
||||
selectedNodeRef.value = ref
|
||||
highlightRef.value = ref
|
||||
}
|
||||
|
||||
onMounted(loadTree)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chunks-tab {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tree-pane {
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
position: relative;
|
||||
transition: width 0.2s;
|
||||
}
|
||||
|
||||
.tree-pane--collapsed {
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
.tree-toggle {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: -1px;
|
||||
z-index: 1;
|
||||
width: 18px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-left: none;
|
||||
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tree-toggle-icon {
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.tree-toggle-icon--collapsed {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.editor-pane {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
44
frontend/src/pages/DocInspectTab.vue
Normal file
44
frontend/src/pages/DocInspectTab.vue
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<template>
|
||||
<div class="inspect-tab" data-e2e="inspect-tab">
|
||||
<div class="coming-soon">
|
||||
<p class="coming-soon-title">{{ t('workspace.inspectComingSoon') }}</p>
|
||||
<p class="coming-soon-sub">{{ t('workspace.inspectComingSoonHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from '../shared/i18n'
|
||||
|
||||
defineProps<{ docId: string }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.inspect-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.coming-soon {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.coming-soon-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.coming-soon-sub {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,45 +1,233 @@
|
|||
<template>
|
||||
<ComingSoonShell
|
||||
:title="t('comingSoon.title')"
|
||||
:subtitle="t('comingSoon.subtitle.docWorkspace')"
|
||||
:hint="hint"
|
||||
/>
|
||||
<div class="workspace-page">
|
||||
<!-- Loading doc -->
|
||||
<div v-if="loadingDoc" class="workspace-loading">
|
||||
<span class="spinner" />
|
||||
</div>
|
||||
|
||||
<!-- Doc error -->
|
||||
<div v-else-if="docError" class="workspace-error">
|
||||
<p>{{ docError }}</p>
|
||||
<RouterLink :to="{ name: ROUTES.DOCS_LIBRARY }" class="back-link">
|
||||
← {{ t('workspace.backToLibrary') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<template v-else-if="doc">
|
||||
<!-- Sticky header (#218) -->
|
||||
<DocWorkspaceHeader :doc="doc" />
|
||||
|
||||
<!-- Tab strip (#216) -->
|
||||
<div class="tab-strip" role="tablist" data-e2e="tab-strip">
|
||||
<button
|
||||
v-for="m in ALL_MODES"
|
||||
:key="m"
|
||||
class="tab-btn"
|
||||
:class="{ active: activeMode === m, disabled: !modeEnabled(m) }"
|
||||
role="tab"
|
||||
:aria-selected="activeMode === m"
|
||||
:disabled="!modeEnabled(m)"
|
||||
:title="!modeEnabled(m) ? t('workspace.modeDisabled') : undefined"
|
||||
@click="switchMode(m)"
|
||||
>
|
||||
{{ t(`workspace.tabs.${m}`) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab content — lazy loaded (#216) -->
|
||||
<div class="tab-content" role="tabpanel" data-e2e="tab-content">
|
||||
<Suspense>
|
||||
<DocChunksTab
|
||||
v-if="activeMode === 'chunks'"
|
||||
:doc-id="id"
|
||||
:available-stores="doc.stores ?? []"
|
||||
/>
|
||||
<DocInspectTab v-else-if="activeMode === 'inspect'" :doc-id="id" />
|
||||
<DocAskTab v-else-if="activeMode === 'ask'" :doc-id="id" />
|
||||
</Suspense>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { RouterLink, useRouter, useRoute } from 'vue-router'
|
||||
import type { Document } from '../shared/types'
|
||||
import { fetchDocument } from '../features/document/api'
|
||||
import { useFeatureFlagStore } from '../features/feature-flags/store'
|
||||
import { ALL_MODES, type DocMode } from '../shared/routing/modes'
|
||||
import { resolveMode } from '../shared/routing/resolveMode'
|
||||
import { useCrumbs } from '../shared/breadcrumb/store'
|
||||
import { truncate } from '../shared/breadcrumb/text'
|
||||
import type { Crumb } from '../shared/breadcrumb/types'
|
||||
import { useI18n } from '../shared/i18n'
|
||||
import { type DocMode } from '../shared/routing/modes'
|
||||
import { ROUTES } from '../shared/routing/names'
|
||||
import ComingSoonShell from '../shared/ui/ComingSoonShell.vue'
|
||||
import DocWorkspaceHeader from '../features/document/ui/DocWorkspaceHeader.vue'
|
||||
import DocChunksTab from './DocChunksTab.vue'
|
||||
import DocInspectTab from './DocInspectTab.vue'
|
||||
import DocAskTab from './DocAskTab.vue'
|
||||
|
||||
/**
|
||||
* Doc workspace placeholder. Receives the doc id (from `:id` path param)
|
||||
* and the resolved mode (from `?mode=` query param, parsed by the
|
||||
* router via `parseMode`). The full workspace is built by #216 (E4)
|
||||
* on top of #218-#224 (E5 chunks editor).
|
||||
*/
|
||||
const props = defineProps<{ id: string; mode: DocMode }>()
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const flagStore = useFeatureFlagStore()
|
||||
|
||||
const hint = computed(() => t('comingSoon.hint.docWorkspace', { id: props.id, mode: props.mode }))
|
||||
const doc = ref<Document | null>(null)
|
||||
const loadingDoc = ref(true)
|
||||
const docError = ref<string | null>(null)
|
||||
|
||||
const activeMode = ref<DocMode>(props.mode)
|
||||
|
||||
// Provide the breadcrumb segments. Once the doc is fetched (E4 / #216),
|
||||
// `truncate(doc.filename, 40)` will replace the id; for now we render
|
||||
// the id itself so the user can still see what they are looking at.
|
||||
const crumbs = computed<Crumb[]>(() => [
|
||||
{ kind: 'link', label: t('breadcrumb.studio'), to: { name: ROUTES.HOME } },
|
||||
{
|
||||
kind: 'link',
|
||||
label: truncate(props.id, 40),
|
||||
label: doc.value ? truncate(doc.value.filename, 40) : truncate(props.id, 40),
|
||||
to: { name: ROUTES.DOC_WORKSPACE, params: { id: props.id } },
|
||||
},
|
||||
{ kind: 'leaf', label: t(`breadcrumb.mode.${props.mode}`) },
|
||||
{ kind: 'leaf', label: t(`breadcrumb.mode.${activeMode.value}`) },
|
||||
])
|
||||
useCrumbs(crumbs)
|
||||
|
||||
function modeEnabled(m: DocMode): boolean {
|
||||
return flagStore.modeFlags()[m]
|
||||
}
|
||||
|
||||
function switchMode(m: DocMode): void {
|
||||
if (!modeEnabled(m)) return
|
||||
activeMode.value = m
|
||||
router.replace({ query: { ...route.query, mode: m } })
|
||||
}
|
||||
|
||||
async function loadDoc(): Promise<void> {
|
||||
loadingDoc.value = true
|
||||
docError.value = null
|
||||
try {
|
||||
doc.value = await fetchDocument(props.id)
|
||||
} catch (e) {
|
||||
docError.value = (e as Error).message || 'Failed to load document'
|
||||
} finally {
|
||||
loadingDoc.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await flagStore.load()
|
||||
|
||||
const flags = flagStore.modeFlags()
|
||||
const resolved = resolveMode(props.mode, flags)
|
||||
if (!resolved) {
|
||||
router.replace({ name: ROUTES.DOCS_LIBRARY, query: { reason: 'no-mode-enabled' } })
|
||||
return
|
||||
}
|
||||
if (resolved !== props.mode) {
|
||||
router.replace({ query: { ...route.query, mode: resolved } })
|
||||
}
|
||||
activeMode.value = resolved
|
||||
|
||||
await loadDoc()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.mode,
|
||||
(m) => {
|
||||
const flags = flagStore.modeFlags()
|
||||
const resolved = resolveMode(m, flags)
|
||||
if (resolved) activeMode.value = resolved
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.workspace-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workspace-loading,
|
||||
.workspace-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 12px;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.workspace-error {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.back-link {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tab-strip {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-surface);
|
||||
padding: 0 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.tab-btn:hover:not(.disabled) {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
color: var(--accent);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.tab-btn.disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 2px solid var(--border-light);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -361,6 +361,53 @@ const messages: Messages = {
|
|||
'settings.about': '\u00C0 propos',
|
||||
'settings.designArticle': 'Comment Docling Studio a \u00e9t\u00e9 con\u00e7u',
|
||||
|
||||
// Doc workspace (#216, #218)
|
||||
'workspace.tabs.ask': 'Ask',
|
||||
'workspace.tabs.inspect': 'Inspect',
|
||||
'workspace.tabs.chunks': 'Chunks',
|
||||
'workspace.backToLibrary': 'Retour \u00e0 la biblioth\u00e8que',
|
||||
'workspace.modeDisabled': 'Mode d\u00e9sactiv\u00e9 pour ce d\u00e9ploiement',
|
||||
'workspace.inspectComingSoon': 'Inspect \u2014 disponible en 0.7.0',
|
||||
'workspace.inspectComingSoonHint': 'La vue arborescence + bbox arrive prochainement.',
|
||||
'workspace.askComingSoon': 'Ask \u2014 disponible en 0.7.0',
|
||||
'workspace.askComingSoonHint':
|
||||
'Le raisonnement agentique sur le document arrive prochainement.',
|
||||
|
||||
// Doc tree rail (#217)
|
||||
'tree.empty': "Aucun n\u0153ud dans l'arbre.",
|
||||
'tree.expand': 'D\u00e9velopper',
|
||||
'tree.collapse': 'R\u00e9duire',
|
||||
|
||||
// Chunks editor (#219, #220, #221, #222)
|
||||
'chunks.count': '{n} chunk(s)',
|
||||
'chunks.empty': "Aucun chunk \u2014 parsez le document d'abord.",
|
||||
'chunks.saving': 'Sauvegarde\u2026',
|
||||
'chunks.noTitle': '(sans titre)',
|
||||
'chunks.editPlaceholder': 'Texte du chunk\u2026',
|
||||
'chunks.mergePrev': 'Fusionner avec le pr\u00e9c\u00e9dent',
|
||||
'chunks.mergeNext': 'Fusionner avec le suivant',
|
||||
'chunks.split': 'Scinder au curseur',
|
||||
'chunks.drop': 'Supprimer',
|
||||
'chunks.add': 'Ajouter un chunk',
|
||||
'chunks.retitle': 'Renommer',
|
||||
'chunks.diffToggle': 'Afficher le diff',
|
||||
'chunks.diffClose': 'Fermer le diff',
|
||||
'chunks.diffRef': 'R\u00e9f\u00e9rence',
|
||||
'chunks.diffAdded': 'ajout\u00e9(s)',
|
||||
'chunks.diffModified': 'modifi\u00e9(s)',
|
||||
'chunks.diffRemoved': 'supprim\u00e9(s)',
|
||||
'chunks.pushTitle': 'Pousser vers un store',
|
||||
'chunks.pushStore': 'Store cible',
|
||||
'chunks.pushPlaceholder': 'Nom du store\u2026',
|
||||
'chunks.pushSummary': '{embeds} chunks \u00b7 ~{tokens} tokens',
|
||||
'chunks.pushConfirm': 'Pousser',
|
||||
'chunks.pushCancel': 'Annuler',
|
||||
'chunks.pushedJob': 'Job lanc\u00e9 : {jobId}',
|
||||
'chunks.selected': '{n} s\u00e9lectionn\u00e9(s)',
|
||||
'chunks.bulkDrop': 'Supprimer la s\u00e9lection',
|
||||
'chunks.bulkMerge': 'Fusionner la s\u00e9lection',
|
||||
'chunks.bulkCancel': 'Annuler',
|
||||
|
||||
// Disclaimer
|
||||
'disclaimer.banner':
|
||||
'Instance de d\u00e9monstration \u2014 les documents upload\u00e9s sont partag\u00e9s et temporaires (max {n} Mo). Ne pas envoyer de fichiers confidentiels.',
|
||||
|
|
@ -705,6 +752,52 @@ const messages: Messages = {
|
|||
'settings.about': 'About',
|
||||
'settings.designArticle': 'How Docling Studio was designed',
|
||||
|
||||
// Doc workspace (#216, #218)
|
||||
'workspace.tabs.ask': 'Ask',
|
||||
'workspace.tabs.inspect': 'Inspect',
|
||||
'workspace.tabs.chunks': 'Chunks',
|
||||
'workspace.backToLibrary': 'Back to library',
|
||||
'workspace.modeDisabled': 'Mode disabled for this deployment',
|
||||
'workspace.inspectComingSoon': 'Inspect \u2014 coming in 0.7.0',
|
||||
'workspace.inspectComingSoonHint': 'Tree + bbox view coming soon.',
|
||||
'workspace.askComingSoon': 'Ask \u2014 coming in 0.7.0',
|
||||
'workspace.askComingSoonHint': 'Agentic reasoning over the document coming soon.',
|
||||
|
||||
// Doc tree rail (#217)
|
||||
'tree.empty': 'No nodes in tree.',
|
||||
'tree.expand': 'Expand',
|
||||
'tree.collapse': 'Collapse',
|
||||
|
||||
// Chunks editor (#219, #220, #221, #222)
|
||||
'chunks.count': '{n} chunk(s)',
|
||||
'chunks.empty': 'No chunks yet \u2014 parse the document first.',
|
||||
'chunks.saving': 'Saving\u2026',
|
||||
'chunks.noTitle': '(untitled)',
|
||||
'chunks.editPlaceholder': 'Chunk text\u2026',
|
||||
'chunks.mergePrev': 'Merge with previous',
|
||||
'chunks.mergeNext': 'Merge with next',
|
||||
'chunks.split': 'Split at cursor',
|
||||
'chunks.drop': 'Drop',
|
||||
'chunks.add': 'Add chunk',
|
||||
'chunks.retitle': 'Rename',
|
||||
'chunks.diffToggle': 'Show diff',
|
||||
'chunks.diffClose': 'Close diff',
|
||||
'chunks.diffRef': 'Reference',
|
||||
'chunks.diffAdded': 'added',
|
||||
'chunks.diffModified': 'modified',
|
||||
'chunks.diffRemoved': 'removed',
|
||||
'chunks.pushTitle': 'Push to store',
|
||||
'chunks.pushStore': 'Target store',
|
||||
'chunks.pushPlaceholder': 'Store name\u2026',
|
||||
'chunks.pushSummary': '{embeds} chunks \u00b7 ~{tokens} tokens',
|
||||
'chunks.pushConfirm': 'Push',
|
||||
'chunks.pushCancel': 'Cancel',
|
||||
'chunks.pushedJob': 'Job dispatched: {jobId}',
|
||||
'chunks.selected': '{n} selected',
|
||||
'chunks.bulkDrop': 'Drop selected',
|
||||
'chunks.bulkMerge': 'Merge selected',
|
||||
'chunks.bulkCancel': 'Cancel',
|
||||
|
||||
// Disclaimer
|
||||
'disclaimer.banner':
|
||||
'Demo instance \u2014 uploaded documents are shared and temporary (max {n} MB). Do not upload confidential files.',
|
||||
|
|
|
|||
|
|
@ -132,3 +132,41 @@ export interface Rect {
|
|||
|
||||
export type Locale = 'fr' | 'en'
|
||||
export type Theme = 'dark' | 'light'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// E4/E5 — Doc workspace tree + chunks (#216–#222)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Node in the parsed document tree returned by GET /api/documents/:id/tree */
|
||||
export interface DocTreeNode {
|
||||
ref: string
|
||||
type: string
|
||||
label: string
|
||||
children: DocTreeNode[]
|
||||
}
|
||||
|
||||
/** Doc-centric chunk (distinct from legacy analysis Chunk). */
|
||||
export interface DocChunk {
|
||||
id: string
|
||||
docId: string
|
||||
text: string
|
||||
title?: string
|
||||
sourceNodeRef?: string
|
||||
pageRange?: [number, number]
|
||||
tokenCount?: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type ChunkDiffStatus = 'added' | 'modified' | 'removed' | 'unchanged'
|
||||
|
||||
export interface ChunkDiff {
|
||||
chunkId: string
|
||||
status: ChunkDiffStatus
|
||||
textDiff?: string
|
||||
}
|
||||
|
||||
export interface PushSummary {
|
||||
embeds: number
|
||||
tokens: number
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue