mirror of
https://github.com/perstarkse/minne.git
synced 2026-01-11 20:50:24 +01:00
310 lines
9.3 KiB
Rust
310 lines
9.3 KiB
Rust
use axum::{
|
|
body::Body,
|
|
extract::{Path, State},
|
|
http::{header, HeaderMap, HeaderValue, StatusCode},
|
|
response::IntoResponse,
|
|
};
|
|
use chrono::{DateTime, Utc};
|
|
use futures::try_join;
|
|
use serde::Serialize;
|
|
|
|
use crate::{
|
|
html_state::HtmlState,
|
|
middlewares::{
|
|
auth_middleware::RequireUser,
|
|
response_middleware::{HtmlError, TemplateResponse},
|
|
},
|
|
utils::text_content_preview::truncate_text_contents,
|
|
AuthSessionType,
|
|
};
|
|
use common::storage::types::user::DashboardStats;
|
|
use common::{
|
|
error::AppError,
|
|
storage::types::{
|
|
conversation::Conversation, file_info::FileInfo, ingestion_task::IngestionTask,
|
|
knowledge_entity::KnowledgeEntity, knowledge_relationship::KnowledgeRelationship,
|
|
text_chunk::TextChunk, text_content::TextContent, user::User,
|
|
},
|
|
};
|
|
|
|
#[derive(Serialize)]
|
|
pub struct IndexPageData {
|
|
user: Option<User>,
|
|
text_contents: Vec<TextContent>,
|
|
stats: DashboardStats,
|
|
active_jobs: Vec<IngestionTask>,
|
|
conversation_archive: Vec<Conversation>,
|
|
}
|
|
|
|
pub async fn index_handler(
|
|
State(state): State<HtmlState>,
|
|
auth: AuthSessionType,
|
|
) -> Result<impl IntoResponse, HtmlError> {
|
|
let Some(user) = auth.current_user else {
|
|
return Ok(TemplateResponse::redirect("/signin"));
|
|
};
|
|
|
|
let (text_contents, conversation_archive, stats, active_jobs) = try_join!(
|
|
User::get_latest_text_contents(&user.id, &state.db),
|
|
User::get_user_conversations(&user.id, &state.db),
|
|
User::get_dashboard_stats(&user.id, &state.db),
|
|
User::get_unfinished_ingestion_tasks(&user.id, &state.db)
|
|
)?;
|
|
|
|
let text_contents = truncate_text_contents(text_contents);
|
|
|
|
Ok(TemplateResponse::new_template(
|
|
"dashboard/base.html",
|
|
IndexPageData {
|
|
user: Some(user),
|
|
text_contents,
|
|
stats,
|
|
conversation_archive,
|
|
active_jobs,
|
|
},
|
|
))
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct LatestTextContentData {
|
|
text_contents: Vec<TextContent>,
|
|
user: User,
|
|
}
|
|
|
|
pub async fn delete_text_content(
|
|
State(state): State<HtmlState>,
|
|
RequireUser(user): RequireUser,
|
|
Path(id): Path<String>,
|
|
) -> Result<impl IntoResponse, HtmlError> {
|
|
// Get and validate TextContent
|
|
let text_content = get_and_validate_text_content(&state, &id, &user).await?;
|
|
|
|
// Remove stored assets before deleting the text content record
|
|
if let Some(file_info) = text_content.file_info.as_ref() {
|
|
let file_in_use =
|
|
TextContent::has_other_with_file(&file_info.id, &text_content.id, &state.db).await?;
|
|
|
|
if !file_in_use {
|
|
FileInfo::delete_by_id_with_storage(&file_info.id, &state.db, &state.storage).await?;
|
|
}
|
|
}
|
|
|
|
// Delete the text content and any related data
|
|
TextChunk::delete_by_source_id(&text_content.id, &state.db).await?;
|
|
KnowledgeEntity::delete_by_source_id(&text_content.id, &state.db).await?;
|
|
KnowledgeRelationship::delete_relationships_by_source_id(&text_content.id, &state.db).await?;
|
|
state
|
|
.db
|
|
.delete_item::<TextContent>(&text_content.id)
|
|
.await?;
|
|
|
|
// Render updated content
|
|
let text_contents =
|
|
truncate_text_contents(User::get_latest_text_contents(&user.id, &state.db).await?);
|
|
|
|
Ok(TemplateResponse::new_partial(
|
|
"dashboard/recent_content.html",
|
|
"latest_content_section",
|
|
LatestTextContentData {
|
|
user: user.clone(),
|
|
text_contents,
|
|
},
|
|
))
|
|
}
|
|
|
|
// Helper function to get and validate text content
|
|
async fn get_and_validate_text_content(
|
|
state: &HtmlState,
|
|
id: &str,
|
|
user: &User,
|
|
) -> Result<TextContent, AppError> {
|
|
let text_content = state
|
|
.db
|
|
.get_item::<TextContent>(id)
|
|
.await?
|
|
.ok_or_else(|| AppError::NotFound("Item was not found".to_string()))?;
|
|
|
|
if text_content.user_id != user.id {
|
|
return Err(AppError::Auth(
|
|
"You are not the owner of that content".to_string(),
|
|
));
|
|
}
|
|
|
|
Ok(text_content)
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct ActiveJobsData {
|
|
pub active_jobs: Vec<IngestionTask>,
|
|
pub user: User,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct TaskArchiveEntry {
|
|
id: String,
|
|
state_label: String,
|
|
state_raw: String,
|
|
attempts: u32,
|
|
max_attempts: u32,
|
|
created_at: DateTime<Utc>,
|
|
updated_at: DateTime<Utc>,
|
|
scheduled_at: DateTime<Utc>,
|
|
locked_at: Option<DateTime<Utc>>,
|
|
last_error_at: Option<DateTime<Utc>>,
|
|
error_message: Option<String>,
|
|
worker_id: Option<String>,
|
|
priority: i32,
|
|
lease_duration_secs: i64,
|
|
content_kind: String,
|
|
content_summary: String,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct TaskArchiveData {
|
|
user: User,
|
|
tasks: Vec<TaskArchiveEntry>,
|
|
}
|
|
|
|
pub async fn delete_job(
|
|
State(state): State<HtmlState>,
|
|
RequireUser(user): RequireUser,
|
|
Path(id): Path<String>,
|
|
) -> Result<impl IntoResponse, HtmlError> {
|
|
User::validate_and_delete_job(&id, &user.id, &state.db).await?;
|
|
|
|
let active_jobs = User::get_unfinished_ingestion_tasks(&user.id, &state.db).await?;
|
|
|
|
Ok(TemplateResponse::new_partial(
|
|
"dashboard/active_jobs.html",
|
|
"active_jobs_section",
|
|
ActiveJobsData {
|
|
user: user.clone(),
|
|
active_jobs,
|
|
},
|
|
))
|
|
}
|
|
|
|
pub async fn show_active_jobs(
|
|
State(state): State<HtmlState>,
|
|
RequireUser(user): RequireUser,
|
|
) -> Result<impl IntoResponse, HtmlError> {
|
|
let active_jobs = User::get_unfinished_ingestion_tasks(&user.id, &state.db).await?;
|
|
|
|
Ok(TemplateResponse::new_template(
|
|
"dashboard/active_jobs.html",
|
|
ActiveJobsData {
|
|
user: user.clone(),
|
|
active_jobs,
|
|
},
|
|
))
|
|
}
|
|
|
|
pub async fn show_task_archive(
|
|
State(state): State<HtmlState>,
|
|
RequireUser(user): RequireUser,
|
|
) -> Result<impl IntoResponse, HtmlError> {
|
|
let tasks = User::get_all_ingestion_tasks(&user.id, &state.db).await?;
|
|
|
|
let entries: Vec<TaskArchiveEntry> = tasks
|
|
.into_iter()
|
|
.map(|task| {
|
|
let (content_kind, content_summary) = summarize_task_content(&task);
|
|
|
|
TaskArchiveEntry {
|
|
id: task.id.clone(),
|
|
state_label: task.state.display_label().to_string(),
|
|
state_raw: task.state.as_str().to_string(),
|
|
attempts: task.attempts,
|
|
max_attempts: task.max_attempts,
|
|
created_at: task.created_at,
|
|
updated_at: task.updated_at,
|
|
scheduled_at: task.scheduled_at,
|
|
locked_at: task.locked_at,
|
|
last_error_at: task.last_error_at,
|
|
error_message: task.error_message.clone(),
|
|
worker_id: task.worker_id.clone(),
|
|
priority: task.priority,
|
|
lease_duration_secs: task.lease_duration_secs,
|
|
content_kind,
|
|
content_summary,
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
Ok(TemplateResponse::new_template(
|
|
"dashboard/task_archive_modal.html",
|
|
TaskArchiveData {
|
|
user,
|
|
tasks: entries,
|
|
},
|
|
))
|
|
}
|
|
|
|
fn summarize_task_content(task: &IngestionTask) -> (String, String) {
|
|
match &task.content {
|
|
common::storage::types::ingestion_payload::IngestionPayload::Text { text, .. } => {
|
|
("Text".to_string(), truncate_summary(text, 80))
|
|
}
|
|
common::storage::types::ingestion_payload::IngestionPayload::Url { url, .. } => {
|
|
("URL".to_string(), url.to_string())
|
|
}
|
|
common::storage::types::ingestion_payload::IngestionPayload::File { file_info, .. } => {
|
|
("File".to_string(), file_info.file_name.clone())
|
|
}
|
|
}
|
|
}
|
|
|
|
fn truncate_summary(input: &str, max_chars: usize) -> String {
|
|
if input.chars().count() <= max_chars {
|
|
input.to_string()
|
|
} else {
|
|
let truncated: String = input.chars().take(max_chars).collect();
|
|
format!("{truncated}…")
|
|
}
|
|
}
|
|
|
|
pub async fn serve_file(
|
|
State(state): State<HtmlState>,
|
|
RequireUser(user): RequireUser,
|
|
Path(file_id): Path<String>,
|
|
) -> Result<impl IntoResponse, HtmlError> {
|
|
let file_info = match FileInfo::get_by_id(&file_id, &state.db).await {
|
|
Ok(info) => info,
|
|
_ => return Ok(TemplateResponse::not_found().into_response()),
|
|
};
|
|
|
|
if file_info.user_id != user.id {
|
|
return Ok(TemplateResponse::unauthorized().into_response());
|
|
}
|
|
|
|
let stream = match state.storage.get_stream(&file_info.path).await {
|
|
Ok(s) => s,
|
|
Err(_) => return Ok(TemplateResponse::server_error().into_response()),
|
|
};
|
|
let body = Body::from_stream(stream);
|
|
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
header::CONTENT_TYPE,
|
|
HeaderValue::from_str(&file_info.mime_type)
|
|
.unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream")),
|
|
);
|
|
let Ok(disposition_value) =
|
|
HeaderValue::from_str(&format!("attachment; filename=\"{}\"", file_info.file_name))
|
|
else {
|
|
headers.insert(
|
|
header::CONTENT_DISPOSITION,
|
|
HeaderValue::from_static("attachment"),
|
|
);
|
|
return Ok((StatusCode::OK, headers, body).into_response());
|
|
};
|
|
headers.insert(header::CONTENT_DISPOSITION, disposition_value);
|
|
|
|
headers.insert(
|
|
header::CACHE_CONTROL,
|
|
HeaderValue::from_static("private, max-age=31536000, immutable"),
|
|
);
|
|
|
|
Ok((StatusCode::OK, headers, body).into_response())
|
|
}
|