feat: category filtering knowledge and content

This commit is contained in:
Per Stark
2025-04-15 11:20:55 +02:00
parent 77b157b3b8
commit fa619a66ba
13 changed files with 492 additions and 121 deletions

View File

@@ -11,7 +11,7 @@ use tokio::fs::remove_dir_all;
use tracing::info;
use uuid::Uuid;
use crate::{storage::db::SurrealDbClient, stored_object};
use crate::{error::AppError, storage::db::SurrealDbClient, stored_object};
#[derive(Error, Debug)]
pub enum FileError {
@@ -221,15 +221,15 @@ impl FileInfo {
///
/// # Returns
/// `Result<(), FileError>`
pub async fn delete_by_id(id: &str, db_client: &SurrealDbClient) -> Result<(), FileError> {
pub async fn delete_by_id(id: &str, db_client: &SurrealDbClient) -> Result<(), AppError> {
// Get the FileInfo from the database
let file_info = match db_client.get_item::<FileInfo>(id).await? {
Some(info) => info,
None => {
return Err(FileError::FileNotFound(format!(
return Err(AppError::from(FileError::FileNotFound(format!(
"File with id {} was not found",
id
)))
))))
}
};
@@ -242,15 +242,15 @@ impl FileInfo {
remove_dir_all(parent_dir).await?;
info!("Removed directory {:?} and its contents", parent_dir);
} else {
return Err(FileError::FileNotFound(
return Err(AppError::from(FileError::FileNotFound(
"File has no parent directory".to_string(),
));
)));
}
} else {
return Err(FileError::FileNotFound(format!(
return Err(AppError::from(FileError::FileNotFound(format!(
"File at path {:?} was not found",
file_path
)));
))));
}
// Delete the FileInfo from the database

View File

@@ -1,5 +1,4 @@
use crate::{error::AppError, storage::types::file_info::FileInfo};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use tracing::info;
use url::Url;

View File

@@ -227,6 +227,50 @@ impl User {
Ok(entities)
}
pub async fn get_knowledge_entities_by_type(
user_id: &str,
entity_type: &str,
db: &SurrealDbClient,
) -> Result<Vec<KnowledgeEntity>, AppError> {
let entities: Vec<KnowledgeEntity> = db
.client
.query("SELECT * FROM type::table($table) WHERE user_id = $user_id AND entity_type = $entity_type")
.bind(("table", KnowledgeEntity::table_name()))
.bind(("user_id", user_id.to_owned()))
.bind(("entity_type", entity_type.to_owned()))
.await?
.take(0)?;
Ok(entities)
}
pub async fn get_entity_types(
user_id: &str,
db: &SurrealDbClient,
) -> Result<Vec<String>, AppError> {
#[derive(Deserialize)]
struct EntityTypeResponse {
entity_type: String,
}
// Query to select distinct entity types for the user
let response: Vec<EntityTypeResponse> = db
.client
.query("SELECT entity_type FROM type::table($table_name) WHERE user_id = $user_id GROUP BY entity_type")
.bind(("user_id", user_id.to_owned()))
.bind(("table_name", KnowledgeEntity::table_name()))
.await?
.take(0)?;
// Extract the entity types from the response
let entity_types: Vec<String> = response
.into_iter()
.map(|item| format!("{:?}", item.entity_type))
.collect();
Ok(entity_types)
}
pub async fn get_knowledge_relationships(
user_id: &str,
db: &SurrealDbClient,
@@ -272,6 +316,23 @@ impl User {
Ok(items)
}
pub async fn get_text_contents_by_category(
user_id: &str,
category: &str,
db: &SurrealDbClient,
) -> Result<Vec<TextContent>, AppError> {
let items: Vec<TextContent> = db
.client
.query("SELECT * FROM type::table($table_name) WHERE user_id = $user_id AND category = $category ORDER BY created_at DESC")
.bind(("user_id", user_id.to_owned()))
.bind(("category", category.to_owned()))
.bind(("table_name", TextContent::table_name()))
.await?
.take(0)?;
Ok(items)
}
pub async fn get_latest_knowledge_entities(
user_id: &str,
db: &SurrealDbClient,
@@ -413,6 +474,34 @@ impl User {
Ok(())
}
pub async fn get_knowledge_entities_by_content_category(
user_id: &str,
category: &str,
db: &SurrealDbClient,
) -> Result<Vec<KnowledgeEntity>, AppError> {
// First, find all text content items with the specified category
let text_contents = Self::get_text_contents_by_category(user_id, category, db).await?;
if text_contents.is_empty() {
return Ok(Vec::new());
}
// Extract source_ids
let source_ids: Vec<String> = text_contents.iter().map(|tc| tc.id.clone()).collect();
// Find all knowledge entities with matching source_ids
let entities: Vec<KnowledgeEntity> = db
.client
.query("SELECT * FROM type::table($table) WHERE user_id = $user_id AND source_id IN $source_ids")
.bind(("table", KnowledgeEntity::table_name()))
.bind(("user_id", user_id.to_owned()))
.bind(("source_ids", source_ids))
.await?
.take(0)?;
Ok(entities)
}
}
#[cfg(test)]

View File

@@ -1,11 +1,12 @@
use axum::{
extract::{Path, State},
extract::{Path, Query, State},
response::IntoResponse,
Form,
};
use axum_htmx::{HxBoosted, HxRequest};
use serde::{Deserialize, Serialize};
use common::storage::types::{text_content::TextContent, user::User};
use common::storage::types::{file_info::FileInfo, text_content::TextContent, user::User};
use crate::{
html_state::HtmlState,
@@ -19,21 +20,52 @@ use crate::{
pub struct ContentPageData {
user: User,
text_contents: Vec<TextContent>,
categories: Vec<String>,
selected_category: Option<String>,
}
#[derive(Deserialize)]
pub struct FilterParams {
category: Option<String>,
}
pub async fn show_content_page(
State(state): State<HtmlState>,
RequireUser(user): RequireUser,
Query(params): Query<FilterParams>,
HxRequest(is_htmx): HxRequest,
HxBoosted(is_boosted): HxBoosted,
) -> Result<impl IntoResponse, HtmlError> {
let text_contents = User::get_text_contents(&user.id, &state.db).await?;
// Normalize empty strings to None
let has_category_param = params.category.is_some();
let category_filter = params.category.as_deref().unwrap_or("").trim();
Ok(TemplateResponse::new_template(
"content/base.html",
ContentPageData {
user,
text_contents,
},
))
// load categories and filtered/all contents
let categories = User::get_user_categories(&user.id, &state.db).await?;
let text_contents = if !category_filter.is_empty() {
User::get_text_contents_by_category(&user.id, category_filter, &state.db).await?
} else {
User::get_text_contents(&user.id, &state.db).await?
};
let data = ContentPageData {
user,
text_contents,
categories,
selected_category: params.category.clone(),
};
if is_htmx && !is_boosted && has_category_param {
// If HTMX partial request with filter applied, return partial content list update
return Ok(TemplateResponse::new_partial(
"content/base.html",
"main",
data,
));
}
// Otherwise full page response including layout
Ok(TemplateResponse::new_template("content/base.html", data))
}
pub async fn show_text_content_edit_form(
@@ -79,12 +111,47 @@ pub async fn patch_text_content(
.await?;
let text_contents = User::get_text_contents(&user.id, &state.db).await?;
let categories = User::get_user_categories(&user.id, &state.db).await?;
Ok(TemplateResponse::new_partial(
"content/base.html",
"main",
ContentPageData {
user,
text_contents,
categories,
selected_category: None,
},
))
}
pub async fn delete_text_content(
State(state): State<HtmlState>,
RequireUser(user): RequireUser,
Path(id): Path<String>,
) -> Result<impl IntoResponse, HtmlError> {
// Get and validate the text content
let text_content = User::get_and_validate_text_content(&id, &user.id, &state.db).await?;
// If it has file info, delete that too
if let Some(file_info) = &text_content.file_info {
FileInfo::delete_by_id(&file_info.id, &state.db).await?;
}
// Delete the text content
state.db.delete_item::<TextContent>(&id).await?;
// Get updated content, categories and return the refreshed list
let text_contents = User::get_text_contents(&user.id, &state.db).await?;
let categories = User::get_user_categories(&user.id, &state.db).await?;
Ok(TemplateResponse::new_template(
"content/content_list.html",
ContentPageData {
user,
text_contents,
categories,
selected_category: None,
},
))
}

View File

@@ -1,7 +1,9 @@
mod handlers;
use axum::{extract::FromRef, routing::get, Router};
use handlers::{patch_text_content, show_content_page, show_text_content_edit_form};
use handlers::{
delete_text_content, patch_text_content, show_content_page, show_text_content_edit_form,
};
use crate::html_state::HtmlState;
@@ -14,6 +16,8 @@ where
.route("/content", get(show_content_page))
.route(
"/content/:id",
get(show_text_content_edit_form).patch(patch_text_content),
get(show_text_content_edit_form)
.patch(patch_text_content)
.delete(delete_text_content),
)
}

View File

@@ -1,12 +1,15 @@
use std::collections::{HashMap, VecDeque};
use axum::{
extract::{Path, State},
extract::{Path, Query, State},
response::IntoResponse,
Form,
};
use axum_htmx::{HxBoosted, HxRequest};
use plotly::{
common::{Line, Marker, Mode},
layout::{Axis, Camera, LayoutScene, ProjectionType},
Layout, Plot, Scatter3D,
layout::{Axis, LayoutScene},
Layout, Plot, Scatter, Scatter3D,
};
use serde::{Deserialize, Serialize};
@@ -24,45 +27,178 @@ use crate::{
},
};
#[derive(Deserialize, Default)]
pub struct FilterParams {
entity_type: Option<String>,
content_category: Option<String>,
}
#[derive(Serialize)]
pub struct KnowledgeBaseData {
entities: Vec<KnowledgeEntity>,
relationships: Vec<KnowledgeRelationship>,
user: User,
plot_html: String,
entity_types: Vec<String>,
content_categories: Vec<String>,
selected_entity_type: Option<String>,
selected_content_category: Option<String>,
}
pub async fn show_knowledge_page(
State(state): State<HtmlState>,
RequireUser(user): RequireUser,
Query(mut params): Query<FilterParams>,
HxRequest(is_htmx): HxRequest,
HxBoosted(is_boosted): HxBoosted,
) -> Result<impl IntoResponse, HtmlError> {
#[derive(Serialize)]
pub struct KnowledgeBaseData {
entities: Vec<KnowledgeEntity>,
relationships: Vec<KnowledgeRelationship>,
user: User,
plot_html: String,
}
// Normalize filters
params.entity_type = params.entity_type.take().filter(|s| !s.trim().is_empty());
params.content_category = params
.content_category
.take()
.filter(|s| !s.trim().is_empty());
let entities = User::get_knowledge_entities(&user.id, &state.db).await?;
// Load relevant data
let entity_types = User::get_entity_types(&user.id, &state.db).await?;
let content_categories = User::get_user_categories(&user.id, &state.db).await?;
// Load entities based on filters
let entities = match &params.content_category {
Some(cat) => {
User::get_knowledge_entities_by_content_category(&user.id, cat, &state.db).await?
}
None => match &params.entity_type {
Some(etype) => User::get_knowledge_entities_by_type(&user.id, etype, &state.db).await?,
None => User::get_knowledge_entities(&user.id, &state.db).await?,
},
};
let relationships = User::get_knowledge_relationships(&user.id, &state.db).await?;
let plot_html = get_plot_html(&entities, &relationships)?;
let mut plot = Plot::new();
let kb_data = KnowledgeBaseData {
entities,
relationships,
user,
plot_html,
entity_types,
content_categories,
selected_entity_type: params.entity_type.clone(),
selected_content_category: params.content_category.clone(),
};
// Fibonacci sphere distribution
let node_count = entities.len();
let golden_ratio = (1.0 + 5.0_f64.sqrt()) / 2.0;
let node_positions: Vec<(f64, f64, f64)> = (0..node_count)
.map(|i| {
let i = i as f64;
let theta = 2.0 * std::f64::consts::PI * i / golden_ratio;
let phi = (1.0 - 2.0 * (i + 0.5) / node_count as f64).acos();
let x = phi.sin() * theta.cos();
let y = phi.sin() * theta.sin();
let z = phi.cos();
(x, y, z)
})
// Determine response type:
// If it is an HTMX request but NOT a boosted navigation, send partial update (main block only)
// Otherwise send full page including navbar/base for direct and boosted reloads
if is_htmx && !is_boosted {
// Partial update (just main block)
Ok(TemplateResponse::new_partial(
"knowledge/base.html",
"main",
&kb_data,
))
} else {
// Full page (includes navbar etc.)
Ok(TemplateResponse::new_template(
"knowledge/base.html",
kb_data,
))
}
}
fn get_plot_html(
entities: &[KnowledgeEntity],
relationships: &[KnowledgeRelationship],
) -> Result<String, HtmlError> {
if entities.is_empty() {
return Ok(String::new());
}
let id_to_idx: HashMap<_, _> = entities
.iter()
.enumerate()
.map(|(i, e)| (e.id.clone(), i))
.collect();
let node_x: Vec<f64> = node_positions.iter().map(|(x, _, _)| *x).collect();
let node_y: Vec<f64> = node_positions.iter().map(|(_, y, _)| *y).collect();
let node_z: Vec<f64> = node_positions.iter().map(|(_, _, z)| *z).collect();
// Build adjacency list
let mut graph: Vec<Vec<usize>> = vec![Vec::new(); entities.len()];
for rel in relationships {
if let (Some(&from_idx), Some(&to_idx)) = (id_to_idx.get(&rel.out), id_to_idx.get(&rel.in_))
{
graph[from_idx].push(to_idx);
graph[to_idx].push(from_idx);
}
}
// Find clusters (connected components)
let mut visited = vec![false; entities.len()];
let mut clusters: Vec<Vec<usize>> = Vec::new();
for i in 0..entities.len() {
if !visited[i] {
let mut queue = VecDeque::new();
let mut cluster = Vec::new();
queue.push_back(i);
visited[i] = true;
while let Some(node) = queue.pop_front() {
cluster.push(node);
for &nbr in &graph[node] {
if !visited[nbr] {
visited[nbr] = true;
queue.push_back(nbr);
}
}
}
clusters.push(cluster);
}
}
// Layout params
let cluster_spacing = 20.0; // Distance between clusters
let node_spacing = 3.0; // Distance between nodes within cluster
// Arrange clusters on a Fibonacci sphere (uniform 3D positioning on unit sphere)
let cluster_count = clusters.len();
let golden_angle = std::f64::consts::PI * (3.0 - (5.0f64).sqrt());
// Will hold final positions of nodes: (x,y,z)
let mut nodes_pos = vec![(0.0f64, 0.0f64, 0.0f64); entities.len()];
for (i, cluster) in clusters.iter().enumerate() {
// Position cluster center on unit sphere scaled by cluster_spacing
let theta = golden_angle * i as f64;
let z = 1.0 - (2.0 * i as f64 + 1.0) / cluster_count as f64;
let radius = (1.0 - z * z).sqrt();
let cluster_center = (
radius * theta.cos() * cluster_spacing,
radius * theta.sin() * cluster_spacing,
z * cluster_spacing,
);
// Layout nodes within cluster as small 3D grid (cube)
// Calculate cube root to determine grid side length
let cluster_size = cluster.len();
let side_len = (cluster_size as f64).cbrt().ceil() as usize;
for (pos_in_cluster, &node_idx) in cluster.iter().enumerate() {
let x_in_cluster = (pos_in_cluster % side_len) as f64;
let y_in_cluster = ((pos_in_cluster / side_len) % side_len) as f64;
let z_in_cluster = (pos_in_cluster / (side_len * side_len)) as f64;
nodes_pos[node_idx] = (
cluster_center.0 + x_in_cluster * node_spacing,
cluster_center.1 + y_in_cluster * node_spacing,
cluster_center.2 + z_in_cluster * node_spacing,
);
}
}
let (node_x, node_y, node_z): (Vec<_>, Vec<_>, Vec<_>) = nodes_pos.iter().cloned().unzip3();
// Nodes trace
let nodes = Scatter3D::new(node_x.clone(), node_y.clone(), node_z.clone())
let nodes_trace = Scatter3D::new(node_x, node_y, node_z)
.mode(Mode::Markers)
.marker(Marker::new().size(8).color("#1f77b4"))
.text_array(
@@ -71,31 +207,32 @@ pub async fn show_knowledge_page(
.map(|e| e.description.clone())
.collect::<Vec<_>>(),
)
.hover_template("Entity: %{text}<br>");
.hover_template("Entity: %{text}<extra></extra>");
// Edges traces
for rel in &relationships {
let from_idx = entities.iter().position(|e| e.id == rel.out).unwrap_or(0);
let to_idx = entities.iter().position(|e| e.id == rel.in_).unwrap_or(0);
let mut plot = Plot::new();
for rel in relationships {
if let (Some(&from_idx), Some(&to_idx)) = (id_to_idx.get(&rel.out), id_to_idx.get(&rel.in_))
{
let edge_x = vec![nodes_pos[from_idx].0, nodes_pos[to_idx].0];
let edge_y = vec![nodes_pos[from_idx].1, nodes_pos[to_idx].1];
let edge_z = vec![nodes_pos[from_idx].2, nodes_pos[to_idx].2];
let edge_x = vec![node_x[from_idx], node_x[to_idx]];
let edge_y = vec![node_y[from_idx], node_y[to_idx]];
let edge_z = vec![node_z[from_idx], node_z[to_idx]];
let edge_trace = Scatter3D::new(edge_x, edge_y, edge_z)
.mode(Mode::Lines)
.line(Line::new().color("#888").width(2.0))
.hover_template(format!(
"Relationship: {}<br>",
rel.metadata.relationship_type
))
.show_legend(false);
plot.add_trace(edge_trace);
let edge_trace = Scatter3D::new(edge_x, edge_y, edge_z)
.mode(Mode::Lines)
.line(Line::new().color("#888").width(2.0))
.hover_template(format!(
"Relationship: {}<extra></extra>",
rel.metadata.relationship_type
))
.show_legend(false);
plot.add_trace(edge_trace);
}
}
plot.add_trace(nodes);
// Layout
plot.add_trace(nodes_trace);
// Layout scene configuration
let layout = Layout::new()
.scene(
LayoutScene::new()
@@ -103,29 +240,37 @@ pub async fn show_knowledge_page(
.y_axis(Axis::new().visible(false))
.z_axis(Axis::new().visible(false))
.camera(
Camera::new()
.projection(ProjectionType::Perspective.into())
.eye((1.5, 1.5, 1.5).into()),
plotly::layout::Camera::new()
.projection(plotly::layout::ProjectionType::Perspective.into())
.eye((2.0, 2.0, 2.0).into()),
),
)
.show_legend(false)
.paper_background_color("rbga(250,100,0,0)")
.plot_background_color("rbga(0,0,0,0)");
.paper_background_color("rgba(255,255,255,0)")
.plot_background_color("rgba(255,255,255,0)");
plot.set_layout(layout);
// Convert to HTML
let html = plot.to_html();
Ok(plot.to_html())
}
Ok(TemplateResponse::new_template(
"knowledge/base.html",
KnowledgeBaseData {
entities,
relationships,
user,
plot_html: html,
},
))
// Small utility to unzip tuple3 vectors from iterators (add this helper)
trait Unzip3<A, B, C> {
fn unzip3(self) -> (Vec<A>, Vec<B>, Vec<C>);
}
impl<I, A, B, C> Unzip3<A, B, C> for I
where
I: Iterator<Item = (A, B, C)>,
{
fn unzip3(self) -> (Vec<A>, Vec<B>, Vec<C>) {
let (mut va, mut vb, mut vc) = (Vec::new(), Vec::new(), Vec::new());
for (a, b, c) in self {
va.push(a);
vb.push(b);
vc.push(c);
}
(va, vb, vc)
}
}
pub async fn show_edit_knowledge_entity_form(
@@ -171,6 +316,10 @@ pub struct PatchKnowledgeEntityParams {
pub struct EntityListData {
entities: Vec<KnowledgeEntity>,
user: User,
entity_types: Vec<String>,
content_categories: Vec<String>,
selected_entity_type: Option<String>,
selected_content_category: Option<String>,
}
pub async fn patch_knowledge_entity(
@@ -197,10 +346,23 @@ pub async fn patch_knowledge_entity(
// Get updated list of entities
let entities = User::get_knowledge_entities(&user.id, &state.db).await?;
// Get entity types
let entity_types = User::get_entity_types(&user.id, &state.db).await?;
// Get content categories
let content_categories = User::get_user_categories(&user.id, &state.db).await?;
// Render updated list
Ok(TemplateResponse::new_template(
"knowledge/entity_list.html",
EntityListData { entities, user },
EntityListData {
entities,
user,
entity_types,
content_categories,
selected_entity_type: None,
selected_content_category: None,
},
))
}
@@ -218,9 +380,22 @@ pub async fn delete_knowledge_entity(
// Get updated list of entities
let entities = User::get_knowledge_entities(&user.id, &state.db).await?;
// Get entity types
let entity_types = User::get_entity_types(&user.id, &state.db).await?;
// Get content categories
let content_categories = User::get_user_categories(&user.id, &state.db).await?;
Ok(TemplateResponse::new_template(
"knowledge/entity_list.html",
EntityListData { entities, user },
EntityListData {
entities,
user,
entity_types,
content_categories,
selected_entity_type: None,
selected_content_category: None,
},
))
}

View File

@@ -1,12 +1,27 @@
{% extends 'body_base.html' %}
{% block main %}
<main class="flex justify-center grow mt-2 sm:mt-4 gap-6 mb-10">
<main id="main_section" class="flex justify-center grow mt-2 sm:mt-4 gap-6 mb-10">
<div class="container">
<h2 class="text-2xl font-bold mb-2">Text Contents</h2>
{% include "content/content_list.html" %}
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-4">
<h2 class="text-2xl font-bold">Text Contents</h2>
<form hx-get="/content" hx-target="#main_section" hx-swap="outerHTML" hx-push-url="true"
class="flex items-center gap-2 mt-2 sm:mt-0">
<div class="form-control">
<select name="category" class="select select-bordered">
<option value="">All Categories</option>
{% for category in categories %}
<option value="{{ category }}" {% if selected_category==category %}selected{% endif %}>{{ category }}
</option>
{% endfor %}
</select>
</div>
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
</form>
</div>
<div id="text_content_cards">
{% include "content/content_list.html" %}
</div>
</div>
</main>
{% endblock %}

View File

@@ -2,16 +2,7 @@
{% for text_content in text_contents %}
<div class="card min-w-72 bg-base-100 shadow">
<div class="card-body">
<div class="flex items-center space-x-2">
<div class="flex-shrink-0">
{% if text_content.url %}
{% include "icons/globe_icon.html" %}
{% elif text_content.file_info %}
{% include "icons/document_icon.html" %}
{% else %}
{% include "icons/chat_icon.html" %}
{% endif %}
</div>
<div class="flex justify-between space-x-2">
<h2 class="card-title truncate">
{% if text_content.url %}
<a href="{{ text_content.url}}">{{text_content.url}}</a>
@@ -21,11 +12,21 @@
{{text_content.text}}
{% endif %}
</h2>
<div class="flex-shrink-0">
{% if text_content.url %}
{% include "icons/globe_icon.html" %}
{% elif text_content.file_info %}
{% include "icons/document_icon.html" %}
{% else %}
{% include "icons/chat_icon.html" %}
{% endif %}
</div>
</div>
<div class="flex items-center">
<div class="flex items-center justify-between">
<p class="text-xs opacity-60">
{{ text_content.created_at | datetimeformat(format="short", tz=user.timezone) }}
</p>
<div class="badge badge-soft badge-secondary mr-2">{{ text_content.category }}</div>
<div class="flex gap-2">
<button hx-get="/content/{{ text_content.id }}" hx-target="#modal" hx-swap="innerHTML"
class="btn btn-square btn-ghost btn-sm">

View File

@@ -2,7 +2,7 @@
{% block form_attributes %}
hx-patch="/content/{{text_content.id}}"
hx-target="#text_content_cards"
hx-target="#main_section"
hx-swap="outerHTML"
{% endblock %}

View File

@@ -23,7 +23,9 @@
{% endif %}
</div>
<div class="text-xs font-semibold opacity-60">
{{item.created_at|datetimeformat(format="short", tz=user.timezone)}} </div>
{{item.created_at|datetimeformat(format="short", tz=user.timezone)}}
<span class="badge badge-xs badge-accent ml-1">{{item.category}}</span>
</div>
</div>
<p class="list-col-wrap text-xs [&:before]:content-['Instructions:_'] [&:before]:opacity-60">
{{item.instructions}}

View File

@@ -1,19 +1,38 @@
{% extends 'body_base.html' %}
{% block main %}
<main class="flex justify-center grow mt-2 sm:mt-4 gap-6 mb-10">
<main id="knowledge_pane" class="flex justify-center grow mt-2 sm:mt-4 gap-6 mb-10">
<div class="container">
<h2 class="text-2xl font-bold mb-2">Entities</h2>
{% include "knowledge/entity_list.html" %}
<h2 class="text-2xl font-bold mb-2 mt-10">Relationships</h2>
{% include "knowledge/relationship_table.html" %}
<div class="rounded-box overflow-clip mt-10 shadow">
{{plot_html|safe}}
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-4">
<h2 class="text-2xl font-bold">Entities</h2>
<form hx-get="/knowledge" hx-target="#knowledge_pane" hx-push-url="true" hx-swap="outerHTML"
class="flex items-center gap-4 mt-2 sm:mt-0">
<div class="form-control">
<select name="entity_type" class="select select-bordered">
<option value="">All Types</option>
{% for type in entity_types %}
<option value="{{ type }}" {% if selected_entity_type==type %}selected{% endif %}>{{ type }}</option>
{% endfor %}
</select>
</div>
<div class="form-control">
<select name="content_category" class="select select-bordered">
<option value="">All Categories</option>
{% for category in content_categories %}
<option value="{{ category }}" {% if selected_content_category==category %}selected{% endif %}>{{ category
}}</option>
{% endfor %}
</select>
</div>
<button type="submit" class="btn btn-primary btn-sm">Filter</button>
</form>
</div>
{% include "knowledge/entity_list.html" %}
<h2 class="text-2xl font-bold mb-2 mt-10">Relationships</h2>
{% include "knowledge/relationship_table.html" %}
<div class="rounded-box overflow-clip mt-10 shadow">
{{ plot_html | safe }}
</div>
</div>
</main>
{% endblock %}

View File

@@ -6,10 +6,10 @@
<span class="badge badge-xs badge-primary">{{entity.entity_type}}</span>
</h2>
<div class="flex justify-between items-center">
<p>{{entity.updated_at | datetimeformat(format="short", tz=user.timezeone)}}</p>
<p class="text-xs opacity-60">{{entity.updated_at | datetimeformat(format="short", tz=user.timezone)}}</p>
<div>
<button hx-get="/knowledge-entity/{{entity.id}}" hx-target="#modal" hx-swap="innerHTML"
class="btn btn-square btn-ghost btn-sm">
class="btn btn-square btn-ghost btn-sm">
{% include "icons/edit_icon.html" %}
</button>
<button hx-delete="/knowledge-entity/{{entity.id}}" hx-target="#entity-list" hx-swap="outerHTML"

View File

@@ -1,6 +1,4 @@
\[\] archive ingressed webpage
\[\] filtering on categories
\[\] testing core functions
\[\] three js graph explorer
\[\] three js vector explorer
\[x\] add user_id to ingress objects
@@ -9,6 +7,7 @@
\[x\] chat history
\[x\] chat styling overhaul
\[x\] configs primarily get envs
\[x\] filtering on categories
\[x\] fix patch_text_content
\[x\] gdpr
\[x\] html ingression
@@ -29,6 +28,7 @@
\[x\] smoothie_dom test
\[x\] template customization?
\[x\] templating
\[x\] testing core functions
\[x\] user id to fileinfo and data path?
\[x\] view content
\[x\] view graph map