9d0a993c2a
Add support for news_item document type in the vector visualization page:
- Add "News" checkbox to document type filter options
- Add URL handler to link news items to /apps/news/item/{id}
- Add content fetching for news items in chunk context expansion
This enables users to search and view news articles in the vector
visualization, with clickable links back to Nextcloud News and the
ability to expand chunks to see full article context.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
256 lines
9.5 KiB
JavaScript
256 lines
9.5 KiB
JavaScript
// Initialize vizApp for vector visualization
|
|
function vizApp() {
|
|
return {
|
|
query: '',
|
|
algorithm: 'bm25_hybrid',
|
|
fusion: 'rrf',
|
|
showAdvanced: false,
|
|
showQueryPoint: true,
|
|
docTypes: [''],
|
|
limit: 50,
|
|
scoreThreshold: 0.0,
|
|
loading: false,
|
|
results: [],
|
|
coordinates: null,
|
|
queryCoords: null,
|
|
expandedChunks: {},
|
|
chunkLoading: {},
|
|
|
|
init() {
|
|
// Set up window resize listener to resize plot
|
|
window.addEventListener('resize', () => {
|
|
if (this.coordinates && this.results.length > 0) {
|
|
Plotly.Plots.resize('viz-plot');
|
|
}
|
|
});
|
|
},
|
|
|
|
async executeSearch() {
|
|
this.loading = true;
|
|
this.results = [];
|
|
|
|
try {
|
|
const params = new URLSearchParams({
|
|
query: this.query,
|
|
algorithm: this.algorithm,
|
|
limit: this.limit,
|
|
score_threshold: this.scoreThreshold,
|
|
});
|
|
|
|
if (this.algorithm === 'bm25_hybrid') {
|
|
params.append('fusion', this.fusion);
|
|
}
|
|
|
|
const selectedTypes = this.docTypes.filter(t => t !== '');
|
|
if (selectedTypes.length > 0) {
|
|
params.append('doc_types', selectedTypes.join(','));
|
|
}
|
|
|
|
const response = await fetch(`/app/vector-viz/search?${params}`);
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
this.results = data.results;
|
|
this.coordinates = data.coordinates_3d;
|
|
this.queryCoords = data.query_coords;
|
|
this.renderPlot(this.coordinates, this.queryCoords, this.results);
|
|
} else {
|
|
alert('Search failed: ' + data.error);
|
|
}
|
|
} catch (error) {
|
|
alert('Error: ' + error.message);
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
|
|
updatePlot() {
|
|
// Toggle query point visibility without recreating the plot
|
|
// This preserves camera position naturally since layout is untouched
|
|
if (this.coordinates && this.queryCoords && this.results.length > 0) {
|
|
const plotDiv = document.getElementById('viz-plot');
|
|
|
|
// If plot exists, just toggle the query trace visibility
|
|
if (plotDiv && plotDiv.data && plotDiv.data.length >= 2) {
|
|
// Trace index 1 is the query point
|
|
Plotly.restyle('viz-plot', { visible: this.showQueryPoint }, [1]);
|
|
} else {
|
|
// Plot doesn't exist yet, render it
|
|
this.renderPlot(this.coordinates, this.queryCoords, this.results);
|
|
}
|
|
}
|
|
},
|
|
|
|
renderPlot(coordinates, queryCoords, results) {
|
|
// Get container dimensions before creating layout
|
|
const container = document.getElementById('viz-plot-container');
|
|
const width = container.clientWidth;
|
|
const height = container.clientHeight;
|
|
|
|
const scores = results.map(r => r.score);
|
|
|
|
// Trace 1: Document results (always visible)
|
|
const documentTrace = {
|
|
x: coordinates.map(c => c[0]),
|
|
y: coordinates.map(c => c[1]),
|
|
z: coordinates.map(c => c[2]),
|
|
mode: 'markers',
|
|
type: 'scatter3d',
|
|
name: 'Documents',
|
|
visible: true,
|
|
customdata: results.map((r, i) => ({
|
|
title: r.title,
|
|
raw_score: r.original_score,
|
|
relative_score: r.score,
|
|
x: coordinates[i][0],
|
|
y: coordinates[i][1],
|
|
z: coordinates[i][2]
|
|
})),
|
|
hovertemplate:
|
|
'<b>%{customdata.title}</b><br>' +
|
|
'Raw Score: %{customdata.raw_score:.3f} (%{customdata.relative_score:.0%} relative)<br>' +
|
|
'(x=%{customdata.x}, y=%{customdata.y}, z=%{customdata.z})' +
|
|
'<extra></extra>',
|
|
marker: {
|
|
size: results.map(r => 4 + (Math.pow(r.score, 2) * 10)),
|
|
opacity: results.map(r => 0.3 + (r.score * 0.7)),
|
|
color: scores,
|
|
colorscale: 'Viridis',
|
|
showscale: true,
|
|
colorbar: {
|
|
title: 'Relative Score',
|
|
x: 1.02,
|
|
xanchor: 'left',
|
|
thickness: 20,
|
|
len: 0.8
|
|
},
|
|
cmin: 0,
|
|
cmax: 1
|
|
}
|
|
};
|
|
|
|
// Trace 2: Query point (visibility controlled by toggle)
|
|
const queryTrace = {
|
|
x: [queryCoords[0]],
|
|
y: [queryCoords[1]],
|
|
z: [queryCoords[2]],
|
|
mode: 'markers',
|
|
type: 'scatter3d',
|
|
name: 'Query',
|
|
visible: this.showQueryPoint, // Initial visibility from state
|
|
hovertemplate:
|
|
'<b>Search Query</b><br>' +
|
|
`(x=${queryCoords[0]}, y=${queryCoords[1]}, z=${queryCoords[2]})` +
|
|
'<extra></extra>',
|
|
marker: {
|
|
size: 10,
|
|
color: '#ef5350', // Subdued red (Material Design Red 400)
|
|
line: {
|
|
color: '#c62828', // Darker red border (Material Design Red 800)
|
|
width: 1
|
|
}
|
|
}
|
|
};
|
|
|
|
const layout = {
|
|
title: `Vector Space (PCA 3D) - ${results.length} results`,
|
|
width: width, // Explicit width from container
|
|
height: height, // Explicit height from container
|
|
scene: {
|
|
xaxis: { title: 'PC1' },
|
|
yaxis: { title: 'PC2' },
|
|
zaxis: { title: 'PC3' },
|
|
camera: {
|
|
eye: { x: 1.5, y: 1.5, z: 1.5 }
|
|
},
|
|
// Full width for 3D scene
|
|
domain: {
|
|
x: [0, 1],
|
|
y: [0, 1]
|
|
}
|
|
},
|
|
hovermode: 'closest',
|
|
autosize: true, // Enable auto-sizing for window resizes
|
|
showlegend: false, // Hide legend
|
|
margin: { l: 0, r: 100, t: 40, b: 0 } // Right margin for colorbar
|
|
};
|
|
|
|
// Always render both traces - visibility is controlled by the visible property
|
|
const traces = [documentTrace, queryTrace];
|
|
|
|
// Enable responsive resizing
|
|
const config = {
|
|
responsive: true,
|
|
displayModeBar: true
|
|
};
|
|
|
|
// Use newPlot() with explicit dimensions - renders at correct size immediately
|
|
// Camera position will be preserved by subsequent Plotly.restyle() calls in updatePlot()
|
|
Plotly.newPlot('viz-plot', traces, layout, config);
|
|
},
|
|
|
|
getNextcloudUrl(result) {
|
|
// Use global NEXTCLOUD_BASE_URL if set, otherwise construct from window location
|
|
const baseUrl = window.NEXTCLOUD_BASE_URL || '';
|
|
switch (result.doc_type) {
|
|
case 'note':
|
|
return `${baseUrl}/apps/notes/note/${result.id}`;
|
|
case 'file':
|
|
return `${baseUrl}/apps/files/?fileId=${result.id}`;
|
|
case 'calendar':
|
|
return `${baseUrl}/apps/calendar`;
|
|
case 'contact':
|
|
return `${baseUrl}/apps/contacts`;
|
|
case 'deck':
|
|
return `${baseUrl}/apps/deck`;
|
|
case 'news_item':
|
|
return `${baseUrl}/apps/news/item/${result.id}`;
|
|
default:
|
|
return `${baseUrl}`;
|
|
}
|
|
},
|
|
|
|
hasChunkPosition(result) {
|
|
return result.chunk_start_offset != null && result.chunk_end_offset != null;
|
|
},
|
|
|
|
isChunkExpanded(resultKey) {
|
|
return this.expandedChunks[resultKey] !== undefined;
|
|
},
|
|
|
|
async toggleChunk(result) {
|
|
const resultKey = `${result.doc_type}_${result.id}_${result.chunk_start_offset || 0}`;
|
|
|
|
if (this.isChunkExpanded(resultKey)) {
|
|
delete this.expandedChunks[resultKey];
|
|
return;
|
|
}
|
|
|
|
this.chunkLoading[resultKey] = true;
|
|
|
|
try {
|
|
const params = new URLSearchParams({
|
|
doc_type: result.doc_type,
|
|
doc_id: result.id,
|
|
start: result.chunk_start_offset,
|
|
end: result.chunk_end_offset,
|
|
context: 500
|
|
});
|
|
|
|
const response = await fetch(`/app/chunk-context?${params}`);
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
this.expandedChunks[resultKey] = data;
|
|
} else {
|
|
alert('Failed to load chunk: ' + data.error);
|
|
}
|
|
} catch (error) {
|
|
alert('Error loading chunk: ' + error.message);
|
|
} finally {
|
|
delete this.chunkLoading[resultKey];
|
|
}
|
|
}
|
|
};
|
|
}
|