chore: checkpoint ai search comments and i18n foundation
This commit is contained in:
@@ -21,7 +21,9 @@ use tower_http::cors::{Any, CorsLayer};
|
||||
#[allow(unused_imports)]
|
||||
use crate::{
|
||||
controllers, initializers,
|
||||
models::_entities::{categories, comments, friend_links, posts, reviews, site_settings, tags, users},
|
||||
models::_entities::{
|
||||
ai_chunks, categories, comments, friend_links, posts, reviews, site_settings, tags, users,
|
||||
},
|
||||
tasks,
|
||||
workers::downloader::DownloadWorker,
|
||||
};
|
||||
@@ -69,12 +71,19 @@ impl Hooks for App {
|
||||
.add_route(controllers::post::routes())
|
||||
.add_route(controllers::search::routes())
|
||||
.add_route(controllers::site_settings::routes())
|
||||
.add_route(controllers::ai::routes())
|
||||
.add_route(controllers::auth::routes())
|
||||
}
|
||||
async fn after_routes(router: AxumRouter, _ctx: &AppContext) -> Result<AxumRouter> {
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::PATCH, Method::DELETE])
|
||||
.allow_methods([
|
||||
Method::GET,
|
||||
Method::POST,
|
||||
Method::PUT,
|
||||
Method::PATCH,
|
||||
Method::DELETE,
|
||||
])
|
||||
.allow_headers(Any);
|
||||
|
||||
Ok(router.layer(cors))
|
||||
@@ -88,10 +97,6 @@ impl Hooks for App {
|
||||
fn register_tasks(tasks: &mut Tasks) {
|
||||
// tasks-inject (do not remove)
|
||||
}
|
||||
async fn truncate(ctx: &AppContext) -> Result<()> {
|
||||
truncate_table(&ctx.db, users::Entity).await?;
|
||||
Ok(())
|
||||
}
|
||||
async fn seed(ctx: &AppContext, base: &Path) -> Result<()> {
|
||||
// Seed users - use loco's default seed which handles duplicates
|
||||
let users_file = base.join("users.yaml");
|
||||
@@ -275,44 +280,59 @@ impl Hooks for App {
|
||||
let item = site_settings::ActiveModel {
|
||||
id: Set(settings["id"].as_i64().unwrap_or(1) as i32),
|
||||
site_name: Set(settings["site_name"].as_str().map(ToString::to_string)),
|
||||
site_short_name: Set(
|
||||
settings["site_short_name"].as_str().map(ToString::to_string),
|
||||
),
|
||||
site_short_name: Set(settings["site_short_name"]
|
||||
.as_str()
|
||||
.map(ToString::to_string)),
|
||||
site_url: Set(settings["site_url"].as_str().map(ToString::to_string)),
|
||||
site_title: Set(settings["site_title"].as_str().map(ToString::to_string)),
|
||||
site_description: Set(
|
||||
settings["site_description"].as_str().map(ToString::to_string),
|
||||
),
|
||||
site_description: Set(settings["site_description"]
|
||||
.as_str()
|
||||
.map(ToString::to_string)),
|
||||
hero_title: Set(settings["hero_title"].as_str().map(ToString::to_string)),
|
||||
hero_subtitle: Set(
|
||||
settings["hero_subtitle"].as_str().map(ToString::to_string),
|
||||
),
|
||||
hero_subtitle: Set(settings["hero_subtitle"]
|
||||
.as_str()
|
||||
.map(ToString::to_string)),
|
||||
owner_name: Set(settings["owner_name"].as_str().map(ToString::to_string)),
|
||||
owner_title: Set(
|
||||
settings["owner_title"].as_str().map(ToString::to_string),
|
||||
),
|
||||
owner_title: Set(settings["owner_title"].as_str().map(ToString::to_string)),
|
||||
owner_bio: Set(settings["owner_bio"].as_str().map(ToString::to_string)),
|
||||
owner_avatar_url: Set(
|
||||
settings["owner_avatar_url"].as_str().and_then(|value| {
|
||||
owner_avatar_url: Set(settings["owner_avatar_url"].as_str().and_then(
|
||||
|value| {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
}),
|
||||
),
|
||||
social_github: Set(
|
||||
settings["social_github"].as_str().map(ToString::to_string),
|
||||
),
|
||||
social_twitter: Set(
|
||||
settings["social_twitter"].as_str().map(ToString::to_string),
|
||||
),
|
||||
social_email: Set(
|
||||
settings["social_email"].as_str().map(ToString::to_string),
|
||||
),
|
||||
},
|
||||
)),
|
||||
social_github: Set(settings["social_github"]
|
||||
.as_str()
|
||||
.map(ToString::to_string)),
|
||||
social_twitter: Set(settings["social_twitter"]
|
||||
.as_str()
|
||||
.map(ToString::to_string)),
|
||||
social_email: Set(settings["social_email"]
|
||||
.as_str()
|
||||
.map(ToString::to_string)),
|
||||
location: Set(settings["location"].as_str().map(ToString::to_string)),
|
||||
tech_stack: Set(tech_stack),
|
||||
ai_enabled: Set(settings["ai_enabled"].as_bool()),
|
||||
ai_provider: Set(settings["ai_provider"].as_str().map(ToString::to_string)),
|
||||
ai_api_base: Set(settings["ai_api_base"].as_str().map(ToString::to_string)),
|
||||
ai_api_key: Set(settings["ai_api_key"].as_str().map(ToString::to_string)),
|
||||
ai_chat_model: Set(settings["ai_chat_model"]
|
||||
.as_str()
|
||||
.map(ToString::to_string)),
|
||||
ai_embedding_model: Set(settings["ai_embedding_model"]
|
||||
.as_str()
|
||||
.map(ToString::to_string)),
|
||||
ai_system_prompt: Set(settings["ai_system_prompt"]
|
||||
.as_str()
|
||||
.map(ToString::to_string)),
|
||||
ai_top_k: Set(settings["ai_top_k"].as_i64().map(|value| value as i32)),
|
||||
ai_chunk_size: Set(settings["ai_chunk_size"]
|
||||
.as_i64()
|
||||
.map(|value| value as i32)),
|
||||
..Default::default()
|
||||
};
|
||||
let _ = item.insert(&ctx.db).await;
|
||||
@@ -365,4 +385,10 @@ impl Hooks for App {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn truncate(ctx: &AppContext) -> Result<()> {
|
||||
truncate_table(&ctx.db, ai_chunks::Entity).await?;
|
||||
truncate_table(&ctx.db, users::Entity).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
use axum::{extract::{Multipart, Query, State}, Form};
|
||||
use axum::{
|
||||
extract::{Multipart, Query, State},
|
||||
Form,
|
||||
};
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, PaginatorTrait, QueryFilter, QueryOrder, Set};
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, PaginatorTrait, QueryFilter,
|
||||
QueryOrder, Set,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use crate::models::_entities::{categories, comments, friend_links, posts, reviews, site_settings, tags};
|
||||
use crate::services::content;
|
||||
use crate::models::_entities::{
|
||||
ai_chunks, categories, comments, friend_links, posts, reviews, site_settings, tags,
|
||||
};
|
||||
use crate::services::{ai, content};
|
||||
|
||||
static ADMIN_LOGGED_IN: AtomicBool = AtomicBool::new(false);
|
||||
const FRONTEND_BASE_URL: &str = "http://localhost:4321";
|
||||
@@ -23,6 +31,14 @@ pub struct LoginQuery {
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
pub struct CommentAdminQuery {
|
||||
scope: Option<String>,
|
||||
approved: Option<String>,
|
||||
post_slug: Option<String>,
|
||||
q: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct HeaderAction {
|
||||
label: String,
|
||||
@@ -89,12 +105,31 @@ struct CommentRow {
|
||||
author: String,
|
||||
post_slug: String,
|
||||
content: String,
|
||||
scope: String,
|
||||
scope_label: String,
|
||||
paragraph_excerpt: String,
|
||||
paragraph_key: String,
|
||||
reply_target: String,
|
||||
approved: bool,
|
||||
created_at: String,
|
||||
frontend_url: Option<String>,
|
||||
api_url: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CommentFilterState {
|
||||
scope: String,
|
||||
approved: String,
|
||||
post_slug: String,
|
||||
q: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CommentFilterStat {
|
||||
label: String,
|
||||
value: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TagRow {
|
||||
id: i32,
|
||||
@@ -174,13 +209,9 @@ fn url_encode(value: &str) -> String {
|
||||
let mut encoded = String::new();
|
||||
for byte in value.as_bytes() {
|
||||
match byte {
|
||||
b'A'..=b'Z'
|
||||
| b'a'..=b'z'
|
||||
| b'0'..=b'9'
|
||||
| b'-'
|
||||
| b'_'
|
||||
| b'.'
|
||||
| b'~' => encoded.push(*byte as char),
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
encoded.push(*byte as char)
|
||||
}
|
||||
b' ' => encoded.push_str("%20"),
|
||||
_ => encoded.push_str(&format!("%{byte:02X}")),
|
||||
}
|
||||
@@ -286,6 +317,60 @@ fn link_status_text(status: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn comment_scope_label(scope: &str) -> &'static str {
|
||||
match scope {
|
||||
"paragraph" => "段落评论",
|
||||
_ => "全文评论",
|
||||
}
|
||||
}
|
||||
|
||||
fn comment_frontend_url(comment: &comments::Model) -> Option<String> {
|
||||
let slug = comment
|
||||
.post_slug
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())?;
|
||||
|
||||
let mut url = frontend_path(&format!("/articles/{slug}"));
|
||||
if comment.scope == "paragraph" {
|
||||
if let Some(paragraph_key) = comment
|
||||
.paragraph_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
url.push('#');
|
||||
url.push_str("paragraph-");
|
||||
url.push_str(paragraph_key);
|
||||
}
|
||||
}
|
||||
|
||||
Some(url)
|
||||
}
|
||||
|
||||
fn normalized_filter_value(value: Option<&str>) -> String {
|
||||
value.unwrap_or_default().trim().to_string()
|
||||
}
|
||||
|
||||
fn comment_matches_query(comment: &comments::Model, query: &str) -> bool {
|
||||
if query.is_empty() {
|
||||
return true;
|
||||
}
|
||||
|
||||
let query = query.to_lowercase();
|
||||
let fields = [
|
||||
comment.author.as_deref().unwrap_or_default(),
|
||||
comment.post_slug.as_deref().unwrap_or_default(),
|
||||
comment.content.as_deref().unwrap_or_default(),
|
||||
comment.paragraph_excerpt.as_deref().unwrap_or_default(),
|
||||
comment.paragraph_key.as_deref().unwrap_or_default(),
|
||||
];
|
||||
|
||||
fields
|
||||
.iter()
|
||||
.any(|value| value.to_lowercase().contains(&query))
|
||||
}
|
||||
|
||||
fn page_context(title: &str, description: &str, active_nav: &str) -> Map<String, Value> {
|
||||
let mut context = Map::new();
|
||||
context.insert("page_title".into(), json!(title));
|
||||
@@ -312,7 +397,7 @@ fn render_admin(
|
||||
format::view(&view_engine.0, template, Value::Object(context))
|
||||
}
|
||||
|
||||
fn check_auth() -> Result<()> {
|
||||
pub(crate) fn check_auth() -> Result<()> {
|
||||
if !ADMIN_LOGGED_IN.load(Ordering::SeqCst) {
|
||||
return Err(Error::Unauthorized("Not logged in".to_string()));
|
||||
}
|
||||
@@ -470,16 +555,26 @@ pub async fn index(
|
||||
];
|
||||
|
||||
let profile = SiteProfile {
|
||||
site_name: non_empty(site.as_ref().and_then(|item| item.site_name.as_deref()), "未配置站点"),
|
||||
site_name: non_empty(
|
||||
site.as_ref().and_then(|item| item.site_name.as_deref()),
|
||||
"未配置站点",
|
||||
),
|
||||
site_description: non_empty(
|
||||
site.as_ref()
|
||||
.and_then(|item| item.site_description.as_deref()),
|
||||
"站点简介尚未设置",
|
||||
),
|
||||
site_url: non_empty(site.as_ref().and_then(|item| item.site_url.as_deref()), "未配置站点链接"),
|
||||
site_url: non_empty(
|
||||
site.as_ref().and_then(|item| item.site_url.as_deref()),
|
||||
"未配置站点链接",
|
||||
),
|
||||
};
|
||||
|
||||
let mut context = page_context("后台总览", "前后台共用同一份数据,这里可以快速处理内容和跳转前台。", "dashboard");
|
||||
let mut context = page_context(
|
||||
"后台总览",
|
||||
"前后台共用同一份数据,这里可以快速处理内容和跳转前台。",
|
||||
"dashboard",
|
||||
);
|
||||
context.insert(
|
||||
"header_actions".into(),
|
||||
json!([
|
||||
@@ -523,7 +618,11 @@ pub async fn posts_admin(
|
||||
file_path: file_path_by_slug
|
||||
.get(&post.slug)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| content::markdown_post_path(&post.slug).to_string_lossy().to_string()),
|
||||
.unwrap_or_else(|| {
|
||||
content::markdown_post_path(&post.slug)
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
}),
|
||||
created_at: post.created_at.format("%Y-%m-%d %H:%M").to_string(),
|
||||
category_name: category_name.clone(),
|
||||
category_frontend_url: frontend_query_url("/articles", "category", &category_name),
|
||||
@@ -535,7 +634,11 @@ pub async fn posts_admin(
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut context = page_context("文章管理", "核对文章、分类和标签,并可直接跳到前台详情页。", "posts");
|
||||
let mut context = page_context(
|
||||
"文章管理",
|
||||
"核对文章、分类和标签,并可直接跳到前台详情页。",
|
||||
"posts",
|
||||
);
|
||||
context.insert(
|
||||
"header_actions".into(),
|
||||
json!([
|
||||
@@ -596,7 +699,11 @@ pub async fn posts_import(
|
||||
|
||||
let mut files = Vec::new();
|
||||
|
||||
while let Some(field) = multipart.next_field().await.map_err(|error| Error::BadRequest(error.to_string()))? {
|
||||
while let Some(field) = multipart
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|error| Error::BadRequest(error.to_string()))?
|
||||
{
|
||||
let file_name = field
|
||||
.file_name()
|
||||
.map(ToString::to_string)
|
||||
@@ -642,9 +749,19 @@ pub async fn post_editor(
|
||||
"header_actions".into(),
|
||||
json!([
|
||||
action("返回文章管理", "/admin/posts".to_string(), "ghost", false),
|
||||
action("前台预览", frontend_path(&format!("/articles/{}", slug)), "primary", true),
|
||||
action(
|
||||
"前台预览",
|
||||
frontend_path(&format!("/articles/{}", slug)),
|
||||
"primary",
|
||||
true
|
||||
),
|
||||
action("文章 API", format!("/api/posts/slug/{slug}"), "ghost", true),
|
||||
action("Markdown API", format!("/api/posts/slug/{slug}/markdown"), "ghost", true),
|
||||
action(
|
||||
"Markdown API",
|
||||
format!("/api/posts/slug/{slug}/markdown"),
|
||||
"ghost",
|
||||
true
|
||||
),
|
||||
]),
|
||||
);
|
||||
context.insert(
|
||||
@@ -662,6 +779,7 @@ pub async fn post_editor(
|
||||
|
||||
pub async fn comments_admin(
|
||||
view_engine: ViewEngine<TeraView>,
|
||||
Query(query): Query<CommentAdminQuery>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
check_auth()?;
|
||||
@@ -672,8 +790,62 @@ pub async fn comments_admin(
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
|
||||
let scope_filter = normalized_filter_value(query.scope.as_deref());
|
||||
let approved_filter = normalized_filter_value(query.approved.as_deref());
|
||||
let post_slug_filter = normalized_filter_value(query.post_slug.as_deref());
|
||||
let text_filter = normalized_filter_value(query.q.as_deref());
|
||||
|
||||
let total_count = items.len();
|
||||
let article_count = items.iter().filter(|comment| comment.scope != "paragraph").count();
|
||||
let paragraph_count = items.iter().filter(|comment| comment.scope == "paragraph").count();
|
||||
let pending_count = items
|
||||
.iter()
|
||||
.filter(|comment| !comment.approved.unwrap_or(false))
|
||||
.count();
|
||||
|
||||
let author_by_id = items
|
||||
.iter()
|
||||
.map(|comment| {
|
||||
(
|
||||
comment.id,
|
||||
non_empty(comment.author.as_deref(), "匿名"),
|
||||
)
|
||||
})
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
|
||||
let post_options = items
|
||||
.iter()
|
||||
.filter_map(|comment| comment.post_slug.as_deref())
|
||||
.map(str::trim)
|
||||
.filter(|slug| !slug.is_empty())
|
||||
.map(ToString::to_string)
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let rows = items
|
||||
.iter()
|
||||
.filter(|comment| {
|
||||
if !scope_filter.is_empty() && comment.scope != scope_filter {
|
||||
return false;
|
||||
}
|
||||
|
||||
if approved_filter == "true" && !comment.approved.unwrap_or(false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if approved_filter == "false" && comment.approved.unwrap_or(false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if !post_slug_filter.is_empty()
|
||||
&& comment.post_slug.as_deref().unwrap_or_default().trim() != post_slug_filter
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
comment_matches_query(comment, &text_filter)
|
||||
})
|
||||
.map(|comment| {
|
||||
let post_slug = non_empty(comment.post_slug.as_deref(), "未关联文章");
|
||||
CommentRow {
|
||||
@@ -681,19 +853,33 @@ pub async fn comments_admin(
|
||||
author: non_empty(comment.author.as_deref(), "匿名"),
|
||||
post_slug: post_slug.clone(),
|
||||
content: non_empty(comment.content.as_deref(), "-"),
|
||||
scope: comment.scope.clone(),
|
||||
scope_label: comment_scope_label(&comment.scope).to_string(),
|
||||
paragraph_excerpt: non_empty(comment.paragraph_excerpt.as_deref(), "-"),
|
||||
paragraph_key: non_empty(comment.paragraph_key.as_deref(), "-"),
|
||||
reply_target: comment
|
||||
.reply_to_comment_id
|
||||
.map(|reply_id| {
|
||||
let author = author_by_id
|
||||
.get(&reply_id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "未知评论".to_string());
|
||||
format!("#{reply_id} · {author}")
|
||||
})
|
||||
.unwrap_or_else(|| "-".to_string()),
|
||||
approved: comment.approved.unwrap_or(false),
|
||||
created_at: comment.created_at.format("%Y-%m-%d %H:%M").to_string(),
|
||||
frontend_url: comment
|
||||
.post_slug
|
||||
.as_deref()
|
||||
.filter(|slug| !slug.trim().is_empty())
|
||||
.map(|slug| frontend_path(&format!("/articles/{slug}"))),
|
||||
frontend_url: comment_frontend_url(comment),
|
||||
api_url: format!("/api/comments/{}", comment.id),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut context = page_context("评论审核", "前台真实评论会先进入这里,审核通过后再展示到文章页。", "comments");
|
||||
let mut context = page_context(
|
||||
"评论审核",
|
||||
"前台真实评论会先进入这里,审核通过后再展示到文章页。",
|
||||
"comments",
|
||||
);
|
||||
context.insert(
|
||||
"header_actions".into(),
|
||||
json!([
|
||||
@@ -701,6 +887,37 @@ pub async fn comments_admin(
|
||||
action("评论 API", "/api/comments".to_string(), "ghost", true),
|
||||
]),
|
||||
);
|
||||
context.insert(
|
||||
"filters".into(),
|
||||
json!(CommentFilterState {
|
||||
scope: scope_filter,
|
||||
approved: approved_filter,
|
||||
post_slug: post_slug_filter,
|
||||
q: text_filter,
|
||||
}),
|
||||
);
|
||||
context.insert("post_options".into(), json!(post_options));
|
||||
context.insert(
|
||||
"stats".into(),
|
||||
json!([
|
||||
CommentFilterStat {
|
||||
label: "全部评论".to_string(),
|
||||
value: total_count.to_string(),
|
||||
},
|
||||
CommentFilterStat {
|
||||
label: "全文评论".to_string(),
|
||||
value: article_count.to_string(),
|
||||
},
|
||||
CommentFilterStat {
|
||||
label: "段落评论".to_string(),
|
||||
value: paragraph_count.to_string(),
|
||||
},
|
||||
CommentFilterStat {
|
||||
label: "待审核".to_string(),
|
||||
value: pending_count.to_string(),
|
||||
},
|
||||
]),
|
||||
);
|
||||
context.insert("rows".into(), json!(rows));
|
||||
|
||||
render_admin(&view_engine, "admin/comments.html", context)
|
||||
@@ -742,7 +959,8 @@ pub async fn categories_admin(
|
||||
.and_then(|post| post.title.as_deref())
|
||||
.unwrap_or("最近文章")
|
||||
.to_string(),
|
||||
latest_frontend_url: latest.map(|post| frontend_path(&format!("/articles/{}", post.slug))),
|
||||
latest_frontend_url: latest
|
||||
.map(|post| frontend_path(&format!("/articles/{}", post.slug))),
|
||||
frontend_url: frontend_path("/categories"),
|
||||
articles_url: frontend_query_url("/articles", "category", &name),
|
||||
api_url: format!("/api/categories/{}", category.id),
|
||||
@@ -758,7 +976,11 @@ pub async fn categories_admin(
|
||||
.then_with(|| left.name.cmp(&right.name))
|
||||
});
|
||||
|
||||
let mut context = page_context("分类管理", "维护分类字典。Markdown 导入文章时,如果分类不存在会自动创建;已存在则复用现有分类。", "categories");
|
||||
let mut context = page_context(
|
||||
"分类管理",
|
||||
"维护分类字典。Markdown 导入文章时,如果分类不存在会自动创建;已存在则复用现有分类。",
|
||||
"categories",
|
||||
);
|
||||
context.insert(
|
||||
"header_actions".into(),
|
||||
json!([
|
||||
@@ -896,7 +1118,11 @@ pub async fn tags_admin(
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut context = page_context("标签管理", "维护标签字典。Markdown 导入文章时,如果标签不存在会自动创建;已存在则复用现有标签。", "tags");
|
||||
let mut context = page_context(
|
||||
"标签管理",
|
||||
"维护标签字典。Markdown 导入文章时,如果标签不存在会自动创建;已存在则复用现有标签。",
|
||||
"tags",
|
||||
);
|
||||
context.insert(
|
||||
"header_actions".into(),
|
||||
json!([
|
||||
@@ -1019,7 +1245,11 @@ pub async fn reviews_admin(
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut context = page_context("评价管理", "创建和编辑评价内容,前台评价页直接读取数据库里的真实数据。", "reviews");
|
||||
let mut context = page_context(
|
||||
"评价管理",
|
||||
"创建和编辑评价内容,前台评价页直接读取数据库里的真实数据。",
|
||||
"reviews",
|
||||
);
|
||||
context.insert(
|
||||
"header_actions".into(),
|
||||
json!([
|
||||
@@ -1058,7 +1288,9 @@ pub async fn reviews_create(
|
||||
review_date: Set(Some(normalize_admin_text(&form.review_date))),
|
||||
status: Set(Some(normalize_admin_text(&form.status))),
|
||||
description: Set(Some(normalize_admin_text(&form.description))),
|
||||
tags: Set(Some(serde_json::to_string(&parse_review_tags(&form.tags)).unwrap_or_default())),
|
||||
tags: Set(Some(
|
||||
serde_json::to_string(&parse_review_tags(&form.tags)).unwrap_or_default(),
|
||||
)),
|
||||
cover: Set(Some(normalize_admin_text(&form.cover))),
|
||||
..Default::default()
|
||||
}
|
||||
@@ -1087,7 +1319,9 @@ pub async fn reviews_update(
|
||||
model.review_date = Set(Some(normalize_admin_text(&form.review_date)));
|
||||
model.status = Set(Some(normalize_admin_text(&form.status)));
|
||||
model.description = Set(Some(normalize_admin_text(&form.description)));
|
||||
model.tags = Set(Some(serde_json::to_string(&parse_review_tags(&form.tags)).unwrap_or_default()));
|
||||
model.tags = Set(Some(
|
||||
serde_json::to_string(&parse_review_tags(&form.tags)).unwrap_or_default(),
|
||||
));
|
||||
model.cover = Set(Some(normalize_admin_text(&form.cover)));
|
||||
let _ = model.update(&ctx.db).await?;
|
||||
|
||||
@@ -1134,7 +1368,11 @@ pub async fn friend_links_admin(
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut context = page_context("友链申请", "处理前台友链申请状态,并跳转到前台友链页或目标站点。", "friend_links");
|
||||
let mut context = page_context(
|
||||
"友链申请",
|
||||
"处理前台友链申请状态,并跳转到前台友链页或目标站点。",
|
||||
"friend_links",
|
||||
);
|
||||
context.insert(
|
||||
"header_actions".into(),
|
||||
json!([
|
||||
@@ -1159,6 +1397,13 @@ fn tech_stack_text(item: &site_settings::Model) -> String {
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn indexed_at_text(item: &site_settings::Model) -> String {
|
||||
item.ai_last_indexed_at
|
||||
.as_ref()
|
||||
.map(|value| value.format("%Y-%m-%d %H:%M:%S UTC").to_string())
|
||||
.unwrap_or_else(|| "尚未建立索引".to_string())
|
||||
}
|
||||
|
||||
pub async fn site_settings_admin(
|
||||
view_engine: ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
@@ -1171,8 +1416,13 @@ pub async fn site_settings_admin(
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.ok_or(Error::NotFound)?;
|
||||
let ai_chunks_count = ai_chunks::Entity::find().count(&ctx.db).await?;
|
||||
|
||||
let mut context = page_context("站点设置", "修改首页、关于页、页脚和友链页读取的站点信息,并直接跳到前台预览。", "site_settings");
|
||||
let mut context = page_context(
|
||||
"站点设置",
|
||||
"修改首页、关于页、页脚和友链页读取的站点信息,并直接跳到前台预览。",
|
||||
"site_settings",
|
||||
);
|
||||
context.insert(
|
||||
"header_actions".into(),
|
||||
json!([
|
||||
@@ -1201,6 +1451,17 @@ pub async fn site_settings_admin(
|
||||
"social_email": non_empty(item.social_email.as_deref(), ""),
|
||||
"owner_bio": non_empty(item.owner_bio.as_deref(), ""),
|
||||
"tech_stack": tech_stack_text(&item),
|
||||
"ai_enabled": item.ai_enabled.unwrap_or(false),
|
||||
"ai_provider": non_empty(item.ai_provider.as_deref(), &ai::provider_name(None)),
|
||||
"ai_api_base": non_empty(item.ai_api_base.as_deref(), ai::default_api_base()),
|
||||
"ai_api_key": non_empty(item.ai_api_key.as_deref(), ""),
|
||||
"ai_chat_model": non_empty(item.ai_chat_model.as_deref(), ai::default_chat_model()),
|
||||
"ai_local_embedding": ai::local_embedding_label(),
|
||||
"ai_system_prompt": non_empty(item.ai_system_prompt.as_deref(), ""),
|
||||
"ai_top_k": item.ai_top_k.unwrap_or(4),
|
||||
"ai_chunk_size": item.ai_chunk_size.unwrap_or(1200),
|
||||
"ai_last_indexed_at": indexed_at_text(&item),
|
||||
"ai_chunks_count": ai_chunks_count,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1217,7 +1478,10 @@ pub fn routes() -> Routes {
|
||||
.add("/admin/posts/import", post(posts_import))
|
||||
.add("/admin/posts/{slug}/edit", get(post_editor))
|
||||
.add("/admin/comments", get(comments_admin))
|
||||
.add("/admin/categories", get(categories_admin).post(categories_create))
|
||||
.add(
|
||||
"/admin/categories",
|
||||
get(categories_admin).post(categories_create),
|
||||
)
|
||||
.add("/admin/categories/{id}/update", post(categories_update))
|
||||
.add("/admin/categories/{id}/delete", post(categories_delete))
|
||||
.add("/admin/tags", get(tags_admin).post(tags_create))
|
||||
|
||||
369
backend/src/controllers/ai.rs
Normal file
369
backend/src/controllers/ai.rs
Normal file
@@ -0,0 +1,369 @@
|
||||
#![allow(clippy::unused_async)]
|
||||
|
||||
use async_stream::stream;
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
http::{
|
||||
header::{CACHE_CONTROL, CONNECTION, CONTENT_TYPE},
|
||||
HeaderValue,
|
||||
},
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use loco_rs::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{controllers::admin::check_auth, services::ai};
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct AskPayload {
|
||||
pub question: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct AskResponse {
|
||||
pub question: String,
|
||||
pub answer: String,
|
||||
pub sources: Vec<ai::AiSource>,
|
||||
pub indexed_chunks: usize,
|
||||
pub last_indexed_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ReindexResponse {
|
||||
pub indexed_chunks: usize,
|
||||
pub last_indexed_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
struct StreamStatusEvent {
|
||||
phase: String,
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
struct StreamDeltaEvent {
|
||||
delta: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
struct StreamErrorEvent {
|
||||
message: String,
|
||||
}
|
||||
|
||||
fn format_timestamp(value: Option<DateTime<Utc>>) -> Option<String> {
|
||||
value.map(|item| item.to_rfc3339())
|
||||
}
|
||||
|
||||
fn sse_bytes<T: Serialize>(event: &str, payload: &T) -> Bytes {
|
||||
let data = serde_json::to_string(payload).unwrap_or_else(|_| {
|
||||
"{\"message\":\"failed to serialize SSE payload\"}".to_string()
|
||||
});
|
||||
|
||||
Bytes::from(format!("event: {event}\ndata: {data}\n\n"))
|
||||
}
|
||||
|
||||
fn take_next_sse_event(buffer: &mut String) -> Option<(Option<String>, String)> {
|
||||
let mut boundary = buffer.find("\n\n").map(|index| (index, 2));
|
||||
if let Some(index) = buffer.find("\r\n\r\n") {
|
||||
match boundary {
|
||||
Some((existing, _)) if existing <= index => {}
|
||||
_ => boundary = Some((index, 4)),
|
||||
}
|
||||
}
|
||||
|
||||
let (index, separator_len) = boundary?;
|
||||
let raw = buffer[..index].to_string();
|
||||
buffer.drain(..index + separator_len);
|
||||
|
||||
let normalized = raw.replace("\r\n", "\n");
|
||||
let mut event = None;
|
||||
let mut data_lines = Vec::new();
|
||||
|
||||
for line in normalized.lines() {
|
||||
if let Some(value) = line.strip_prefix("event:") {
|
||||
event = Some(value.trim().to_string());
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(value) = line.strip_prefix("data:") {
|
||||
data_lines.push(value.trim_start().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Some((event, data_lines.join("\n")))
|
||||
}
|
||||
|
||||
fn extract_stream_delta(value: &Value) -> Option<String> {
|
||||
if let Some(delta) = value.get("delta").and_then(Value::as_str) {
|
||||
return Some(delta.to_string());
|
||||
}
|
||||
|
||||
if let Some(content) = value
|
||||
.get("choices")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|choices| choices.first())
|
||||
.and_then(|choice| choice.get("delta"))
|
||||
.and_then(|delta| delta.get("content"))
|
||||
{
|
||||
if let Some(text) = content.as_str() {
|
||||
return Some(text.to_string());
|
||||
}
|
||||
|
||||
if let Some(parts) = content.as_array() {
|
||||
let merged = parts
|
||||
.iter()
|
||||
.filter_map(|part| {
|
||||
part.get("text")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| part.as_str())
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
|
||||
if !merged.is_empty() {
|
||||
return Some(merged);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
value.get("choices")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|choices| choices.first())
|
||||
.and_then(|choice| choice.get("text"))
|
||||
.and_then(Value::as_str)
|
||||
.map(ToString::to_string)
|
||||
}
|
||||
|
||||
fn append_missing_suffix(accumulated: &mut String, full_text: &str) -> Option<String> {
|
||||
if full_text.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if accumulated.is_empty() {
|
||||
accumulated.push_str(full_text);
|
||||
return Some(full_text.to_string());
|
||||
}
|
||||
|
||||
if full_text.starts_with(accumulated.as_str()) {
|
||||
let suffix = full_text[accumulated.len()..].to_string();
|
||||
if !suffix.is_empty() {
|
||||
accumulated.push_str(&suffix);
|
||||
return Some(suffix);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn chunk_text(value: &str, chunk_size: usize) -> Vec<String> {
|
||||
let chars = value.chars().collect::<Vec<_>>();
|
||||
chars
|
||||
.chunks(chunk_size.max(1))
|
||||
.map(|chunk| chunk.iter().collect::<String>())
|
||||
.filter(|chunk| !chunk.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
|
||||
fn build_ask_response(prepared: &ai::PreparedAiAnswer, answer: String) -> AskResponse {
|
||||
AskResponse {
|
||||
question: prepared.question.clone(),
|
||||
answer,
|
||||
sources: prepared.sources.clone(),
|
||||
indexed_chunks: prepared.indexed_chunks,
|
||||
last_indexed_at: format_timestamp(prepared.last_indexed_at),
|
||||
}
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn ask(
|
||||
State(ctx): State<AppContext>,
|
||||
Json(payload): Json<AskPayload>,
|
||||
) -> Result<Response> {
|
||||
let result = ai::answer_question(&ctx, &payload.question).await?;
|
||||
format::json(AskResponse {
|
||||
question: payload.question.trim().to_string(),
|
||||
answer: result.answer,
|
||||
sources: result.sources,
|
||||
indexed_chunks: result.indexed_chunks,
|
||||
last_indexed_at: format_timestamp(result.last_indexed_at),
|
||||
})
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn ask_stream(
|
||||
State(ctx): State<AppContext>,
|
||||
Json(payload): Json<AskPayload>,
|
||||
) -> Result<Response> {
|
||||
let stream = stream! {
|
||||
yield Ok::<Bytes, std::io::Error>(sse_bytes("status", &StreamStatusEvent {
|
||||
phase: "retrieving".to_string(),
|
||||
message: "正在检索知识库上下文...".to_string(),
|
||||
}));
|
||||
|
||||
let prepared = match ai::prepare_answer(&ctx, &payload.question).await {
|
||||
Ok(prepared) => prepared,
|
||||
Err(error) => {
|
||||
yield Ok(sse_bytes("error", &StreamErrorEvent {
|
||||
message: error.to_string(),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut accumulated_answer = String::new();
|
||||
|
||||
if let Some(answer) = prepared.immediate_answer.as_deref() {
|
||||
yield Ok(sse_bytes("status", &StreamStatusEvent {
|
||||
phase: "answering".to_string(),
|
||||
message: "已完成检索,正在输出检索结论...".to_string(),
|
||||
}));
|
||||
|
||||
for chunk in chunk_text(answer, 48) {
|
||||
accumulated_answer.push_str(&chunk);
|
||||
yield Ok(sse_bytes("delta", &StreamDeltaEvent { delta: chunk }));
|
||||
}
|
||||
} else if let Some(provider_request) = prepared.provider_request.as_ref() {
|
||||
yield Ok(sse_bytes("status", &StreamStatusEvent {
|
||||
phase: "streaming".to_string(),
|
||||
message: "已命中相关资料,正在流式生成回答...".to_string(),
|
||||
}));
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let response = client
|
||||
.post(ai::build_provider_url(provider_request))
|
||||
.bearer_auth(&provider_request.api_key)
|
||||
.header("Accept", "text/event-stream, application/json")
|
||||
.json(&ai::build_provider_payload(provider_request, true))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
let mut response = match response {
|
||||
Ok(response) => response,
|
||||
Err(error) => {
|
||||
yield Ok(sse_bytes("error", &StreamErrorEvent {
|
||||
message: format!("AI request failed: {error}"),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
yield Ok(sse_bytes("error", &StreamErrorEvent {
|
||||
message: format!("AI provider returned {status}: {body}"),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
let mut sse_buffer = String::new();
|
||||
let mut last_full_answer = None;
|
||||
|
||||
loop {
|
||||
let next_chunk = response.chunk().await;
|
||||
let Some(chunk) = (match next_chunk {
|
||||
Ok(chunk) => chunk,
|
||||
Err(error) => {
|
||||
yield Ok(sse_bytes("error", &StreamErrorEvent {
|
||||
message: format!("AI stream read failed: {error}"),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
}) else {
|
||||
break;
|
||||
};
|
||||
|
||||
sse_buffer.push_str(&String::from_utf8_lossy(&chunk));
|
||||
|
||||
while let Some((_event_name, data)) = take_next_sse_event(&mut sse_buffer) {
|
||||
let trimmed = data.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if trimmed == "[DONE]" {
|
||||
continue;
|
||||
}
|
||||
|
||||
let parsed = match serde_json::from_str::<Value>(trimmed) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if let Some(full_text) = ai::extract_provider_text(&parsed) {
|
||||
last_full_answer = Some(full_text);
|
||||
}
|
||||
|
||||
if let Some(delta) = extract_stream_delta(&parsed) {
|
||||
accumulated_answer.push_str(&delta);
|
||||
yield Ok(sse_bytes("delta", &StreamDeltaEvent { delta }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let leftover = sse_buffer.trim();
|
||||
if !leftover.is_empty() && leftover != "[DONE]" {
|
||||
if let Ok(parsed) = serde_json::from_str::<Value>(leftover) {
|
||||
if let Some(full_text) = ai::extract_provider_text(&parsed) {
|
||||
last_full_answer = Some(full_text);
|
||||
}
|
||||
|
||||
if let Some(delta) = extract_stream_delta(&parsed) {
|
||||
accumulated_answer.push_str(&delta);
|
||||
yield Ok(sse_bytes("delta", &StreamDeltaEvent { delta }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(full_text) = last_full_answer {
|
||||
if let Some(suffix) = append_missing_suffix(&mut accumulated_answer, &full_text) {
|
||||
yield Ok(sse_bytes("delta", &StreamDeltaEvent { delta: suffix }));
|
||||
}
|
||||
}
|
||||
|
||||
if accumulated_answer.is_empty() {
|
||||
yield Ok(sse_bytes("error", &StreamErrorEvent {
|
||||
message: "AI chat response did not contain readable content".to_string(),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let final_payload = build_ask_response(&prepared, accumulated_answer);
|
||||
yield Ok(sse_bytes("complete", &final_payload));
|
||||
};
|
||||
|
||||
let mut response = Response::new(Body::from_stream(stream));
|
||||
response.headers_mut().insert(
|
||||
CONTENT_TYPE,
|
||||
HeaderValue::from_static("text/event-stream; charset=utf-8"),
|
||||
);
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(CACHE_CONTROL, HeaderValue::from_static("no-cache"));
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(CONNECTION, HeaderValue::from_static("keep-alive"));
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn reindex(State(ctx): State<AppContext>) -> Result<Response> {
|
||||
check_auth()?;
|
||||
let summary = ai::rebuild_index(&ctx).await?;
|
||||
|
||||
format::json(ReindexResponse {
|
||||
indexed_chunks: summary.indexed_chunks,
|
||||
last_indexed_at: format_timestamp(summary.last_indexed_at),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.prefix("api/ai/")
|
||||
.add("/ask", post(ask))
|
||||
.add("/ask/stream", post(ask_stream))
|
||||
.add("/reindex", post(reindex))
|
||||
}
|
||||
@@ -136,16 +136,32 @@ pub async fn update(
|
||||
let name = normalized_name(¶ms)?;
|
||||
let slug = normalized_slug(¶ms, &name);
|
||||
let item = load_item(&ctx, id).await?;
|
||||
let previous_name = item.name.clone();
|
||||
let previous_slug = item.slug.clone();
|
||||
|
||||
if previous_name
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
!= Some(name.as_str())
|
||||
{
|
||||
content::rewrite_category_references(previous_name.as_deref(), &previous_slug, Some(&name))?;
|
||||
}
|
||||
|
||||
let mut item = item.into_active_model();
|
||||
item.name = Set(Some(name));
|
||||
item.slug = Set(slug);
|
||||
let item = item.update(&ctx.db).await?;
|
||||
content::sync_markdown_posts(&ctx).await?;
|
||||
format::json(item)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn remove(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {
|
||||
load_item(&ctx, id).await?.delete(&ctx.db).await?;
|
||||
let item = load_item(&ctx, id).await?;
|
||||
content::rewrite_category_references(item.name.as_deref(), &item.slug, None)?;
|
||||
item.delete(&ctx.db).await?;
|
||||
content::sync_markdown_posts(&ctx).await?;
|
||||
format::empty()
|
||||
}
|
||||
|
||||
|
||||
@@ -4,12 +4,16 @@
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{ColumnTrait, QueryFilter, QueryOrder};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::models::_entities::{
|
||||
comments::{ActiveModel, Column, Entity, Model},
|
||||
posts,
|
||||
};
|
||||
|
||||
const ARTICLE_SCOPE: &str = "article";
|
||||
const PARAGRAPH_SCOPE: &str = "paragraph";
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Params {
|
||||
pub post_id: Option<Uuid>,
|
||||
@@ -19,6 +23,10 @@ pub struct Params {
|
||||
pub avatar: Option<String>,
|
||||
pub content: Option<String>,
|
||||
pub reply_to: Option<Uuid>,
|
||||
pub reply_to_comment_id: Option<i32>,
|
||||
pub scope: Option<String>,
|
||||
pub paragraph_key: Option<String>,
|
||||
pub paragraph_excerpt: Option<String>,
|
||||
pub approved: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -45,6 +53,18 @@ impl Params {
|
||||
if let Some(reply_to) = self.reply_to {
|
||||
item.reply_to = Set(Some(reply_to));
|
||||
}
|
||||
if let Some(reply_to_comment_id) = self.reply_to_comment_id {
|
||||
item.reply_to_comment_id = Set(Some(reply_to_comment_id));
|
||||
}
|
||||
if let Some(scope) = &self.scope {
|
||||
item.scope = Set(scope.clone());
|
||||
}
|
||||
if let Some(paragraph_key) = &self.paragraph_key {
|
||||
item.paragraph_key = Set(Some(paragraph_key.clone()));
|
||||
}
|
||||
if let Some(paragraph_excerpt) = &self.paragraph_excerpt {
|
||||
item.paragraph_excerpt = Set(Some(paragraph_excerpt.clone()));
|
||||
}
|
||||
if let Some(approved) = self.approved {
|
||||
item.approved = Set(Some(approved));
|
||||
}
|
||||
@@ -55,6 +75,8 @@ impl Params {
|
||||
pub struct ListQuery {
|
||||
pub post_id: Option<String>,
|
||||
pub post_slug: Option<String>,
|
||||
pub scope: Option<String>,
|
||||
pub paragraph_key: Option<String>,
|
||||
pub approved: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -74,10 +96,58 @@ pub struct CreateCommentRequest {
|
||||
pub content: Option<String>,
|
||||
#[serde(default, alias = "replyTo")]
|
||||
pub reply_to: Option<String>,
|
||||
#[serde(default, alias = "replyToCommentId")]
|
||||
pub reply_to_comment_id: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub scope: Option<String>,
|
||||
#[serde(default, alias = "paragraphKey")]
|
||||
pub paragraph_key: Option<String>,
|
||||
#[serde(default, alias = "paragraphExcerpt")]
|
||||
pub paragraph_excerpt: Option<String>,
|
||||
#[serde(default)]
|
||||
pub approved: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ParagraphCommentSummary {
|
||||
pub paragraph_key: String,
|
||||
pub count: usize,
|
||||
}
|
||||
|
||||
fn normalize_optional_string(value: Option<String>) -> Option<String> {
|
||||
value.and_then(|item| {
|
||||
let trimmed = item.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn normalized_scope(value: Option<String>) -> Result<String> {
|
||||
match value
|
||||
.unwrap_or_else(|| ARTICLE_SCOPE.to_string())
|
||||
.trim()
|
||||
.to_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
ARTICLE_SCOPE => Ok(ARTICLE_SCOPE.to_string()),
|
||||
PARAGRAPH_SCOPE => Ok(PARAGRAPH_SCOPE.to_string()),
|
||||
_ => Err(Error::BadRequest("invalid comment scope".to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
fn preview_excerpt(value: &str) -> Option<String> {
|
||||
let flattened = value.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
let excerpt = flattened.chars().take(120).collect::<String>();
|
||||
if excerpt.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(excerpt)
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_item(ctx: &AppContext, id: i32) -> Result<Model> {
|
||||
let item = Entity::find_by_id(id).one(&ctx.db).await?;
|
||||
item.ok_or_else(|| Error::NotFound)
|
||||
@@ -116,6 +186,19 @@ pub async fn list(
|
||||
db_query = db_query.filter(Column::PostSlug.eq(post_slug));
|
||||
}
|
||||
|
||||
if let Some(scope) = query.scope {
|
||||
db_query = db_query.filter(Column::Scope.eq(scope.trim().to_lowercase()));
|
||||
}
|
||||
|
||||
if let Some(paragraph_key) = query
|
||||
.paragraph_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
db_query = db_query.filter(Column::ParagraphKey.eq(paragraph_key));
|
||||
}
|
||||
|
||||
if let Some(approved) = query.approved {
|
||||
db_query = db_query.filter(Column::Approved.eq(approved));
|
||||
}
|
||||
@@ -123,18 +206,87 @@ pub async fn list(
|
||||
format::json(db_query.all(&ctx.db).await?)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn paragraph_summary(
|
||||
Query(query): Query<ListQuery>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let post_slug = if let Some(post_slug) = query.post_slug {
|
||||
Some(post_slug)
|
||||
} else if let Some(post_id) = query.post_id {
|
||||
resolve_post_slug(&ctx, &post_id).await?
|
||||
} else {
|
||||
None
|
||||
}
|
||||
.ok_or_else(|| Error::BadRequest("post_slug is required".to_string()))?;
|
||||
|
||||
let items = Entity::find()
|
||||
.filter(Column::PostSlug.eq(post_slug))
|
||||
.filter(Column::Scope.eq(PARAGRAPH_SCOPE))
|
||||
.filter(Column::Approved.eq(true))
|
||||
.order_by_asc(Column::CreatedAt)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
|
||||
let mut counts = BTreeMap::<String, usize>::new();
|
||||
for item in items {
|
||||
let Some(paragraph_key) = item.paragraph_key.as_deref() else {
|
||||
continue;
|
||||
};
|
||||
let key = paragraph_key.trim();
|
||||
if key.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
*counts.entry(key.to_string()).or_default() += 1;
|
||||
}
|
||||
|
||||
let summary = counts
|
||||
.into_iter()
|
||||
.map(|(paragraph_key, count)| ParagraphCommentSummary { paragraph_key, count })
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
format::json(summary)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn add(
|
||||
State(ctx): State<AppContext>,
|
||||
Json(params): Json<CreateCommentRequest>,
|
||||
) -> Result<Response> {
|
||||
let scope = normalized_scope(params.scope.clone())?;
|
||||
let post_slug = if let Some(post_slug) = params.post_slug.as_deref() {
|
||||
Some(post_slug.to_string())
|
||||
} else if let Some(post_id) = params.post_id.as_deref() {
|
||||
resolve_post_slug(&ctx, post_id).await?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
}
|
||||
.and_then(|value| normalize_optional_string(Some(value)));
|
||||
|
||||
let author = normalize_optional_string(params.author);
|
||||
let email = normalize_optional_string(params.email);
|
||||
let avatar = normalize_optional_string(params.avatar);
|
||||
let content = normalize_optional_string(params.content);
|
||||
let paragraph_key = normalize_optional_string(params.paragraph_key);
|
||||
let paragraph_excerpt = normalize_optional_string(params.paragraph_excerpt)
|
||||
.or_else(|| content.as_deref().and_then(preview_excerpt));
|
||||
|
||||
if post_slug.is_none() {
|
||||
return Err(Error::BadRequest("post_slug is required".to_string()));
|
||||
}
|
||||
|
||||
if author.is_none() {
|
||||
return Err(Error::BadRequest("author is required".to_string()));
|
||||
}
|
||||
|
||||
if content.is_none() {
|
||||
return Err(Error::BadRequest("content is required".to_string()));
|
||||
}
|
||||
|
||||
if scope == PARAGRAPH_SCOPE && paragraph_key.is_none() {
|
||||
return Err(Error::BadRequest("paragraph_key is required".to_string()));
|
||||
}
|
||||
|
||||
let mut item = ActiveModel {
|
||||
..Default::default()
|
||||
@@ -144,14 +296,18 @@ pub async fn add(
|
||||
.as_deref()
|
||||
.and_then(|value| Uuid::parse_str(value).ok()));
|
||||
item.post_slug = Set(post_slug);
|
||||
item.author = Set(params.author);
|
||||
item.email = Set(params.email);
|
||||
item.avatar = Set(params.avatar);
|
||||
item.content = Set(params.content);
|
||||
item.author = Set(author);
|
||||
item.email = Set(email);
|
||||
item.avatar = Set(avatar);
|
||||
item.content = Set(content);
|
||||
item.scope = Set(scope);
|
||||
item.paragraph_key = Set(paragraph_key);
|
||||
item.paragraph_excerpt = Set(paragraph_excerpt);
|
||||
item.reply_to = Set(params
|
||||
.reply_to
|
||||
.as_deref()
|
||||
.and_then(|value| Uuid::parse_str(value).ok()));
|
||||
item.reply_to_comment_id = Set(params.reply_to_comment_id);
|
||||
item.approved = Set(Some(params.approved.unwrap_or(false)));
|
||||
let item = item.insert(&ctx.db).await?;
|
||||
format::json(item)
|
||||
@@ -185,6 +341,7 @@ pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.prefix("api/comments/")
|
||||
.add("/", get(list))
|
||||
.add("paragraphs/summary", get(paragraph_summary))
|
||||
.add("/", post(add))
|
||||
.add("{id}", get(get_one))
|
||||
.add("{id}", delete(remove))
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod admin;
|
||||
pub mod ai;
|
||||
pub mod auth;
|
||||
pub mod category;
|
||||
pub mod comment;
|
||||
|
||||
@@ -51,6 +51,20 @@ pub struct MarkdownUpdateParams {
|
||||
pub markdown: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct MarkdownCreateParams {
|
||||
pub title: String,
|
||||
pub slug: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub content: Option<String>,
|
||||
pub category: Option<String>,
|
||||
pub tags: Option<Vec<String>>,
|
||||
pub post_type: Option<String>,
|
||||
pub image: Option<String>,
|
||||
pub pinned: Option<bool>,
|
||||
pub published: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct MarkdownDocumentResponse {
|
||||
pub slug: String,
|
||||
@@ -58,6 +72,12 @@ pub struct MarkdownDocumentResponse {
|
||||
pub markdown: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct MarkdownDeleteResponse {
|
||||
pub slug: String,
|
||||
pub deleted: bool,
|
||||
}
|
||||
|
||||
async fn load_item(ctx: &AppContext, id: i32) -> Result<Model> {
|
||||
let item = Entity::find_by_id(id).one(&ctx.db).await?;
|
||||
item.ok_or_else(|| Error::NotFound)
|
||||
@@ -228,7 +248,11 @@ pub async fn get_markdown_by_slug(
|
||||
) -> Result<Response> {
|
||||
content::sync_markdown_posts(&ctx).await?;
|
||||
let (path, markdown) = content::read_markdown_document(&slug)?;
|
||||
format::json(MarkdownDocumentResponse { slug, path, markdown })
|
||||
format::json(MarkdownDocumentResponse {
|
||||
slug,
|
||||
path,
|
||||
markdown,
|
||||
})
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
@@ -247,14 +271,64 @@ pub async fn update_markdown_by_slug(
|
||||
})
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn create_markdown(
|
||||
State(ctx): State<AppContext>,
|
||||
Json(params): Json<MarkdownCreateParams>,
|
||||
) -> Result<Response> {
|
||||
let title = params.title.trim();
|
||||
if title.is_empty() {
|
||||
return Err(Error::BadRequest("title is required".to_string()));
|
||||
}
|
||||
|
||||
let default_body = format!("# {title}\n");
|
||||
let created = content::create_markdown_post(
|
||||
&ctx,
|
||||
content::MarkdownPostDraft {
|
||||
title: title.to_string(),
|
||||
slug: params.slug,
|
||||
description: params.description,
|
||||
content: params.content.unwrap_or(default_body),
|
||||
category: params.category,
|
||||
tags: params.tags.unwrap_or_default(),
|
||||
post_type: params.post_type.unwrap_or_else(|| "article".to_string()),
|
||||
image: params.image,
|
||||
pinned: params.pinned.unwrap_or(false),
|
||||
published: params.published.unwrap_or(true),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let (path, markdown) = content::read_markdown_document(&created.slug)?;
|
||||
|
||||
format::json(MarkdownDocumentResponse {
|
||||
slug: created.slug,
|
||||
path,
|
||||
markdown,
|
||||
})
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn delete_markdown_by_slug(
|
||||
Path(slug): Path<String>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
content::delete_markdown_post(&ctx, &slug).await?;
|
||||
format::json(MarkdownDeleteResponse {
|
||||
slug,
|
||||
deleted: true,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.prefix("api/posts/")
|
||||
.add("/", get(list))
|
||||
.add("/", post(add))
|
||||
.add("markdown", post(create_markdown))
|
||||
.add("slug/{slug}/markdown", get(get_markdown_by_slug))
|
||||
.add("slug/{slug}/markdown", put(update_markdown_by_slug))
|
||||
.add("slug/{slug}/markdown", patch(update_markdown_by_slug))
|
||||
.add("slug/{slug}/markdown", delete(delete_markdown_by_slug))
|
||||
.add("slug/{slug}", get(get_by_slug))
|
||||
.add("{id}", get(get_one))
|
||||
.add("{id}", delete(remove))
|
||||
|
||||
@@ -174,7 +174,10 @@ pub async fn search(
|
||||
[q.clone().into(), (limit as i64).into()],
|
||||
);
|
||||
|
||||
match SearchResult::find_by_statement(statement).all(&ctx.db).await {
|
||||
match SearchResult::find_by_statement(statement)
|
||||
.all(&ctx.db)
|
||||
.await
|
||||
{
|
||||
Ok(rows) => rows,
|
||||
Err(_) => fallback_search(&ctx, &q, limit).await?,
|
||||
}
|
||||
|
||||
@@ -6,7 +6,11 @@ use loco_rs::prelude::*;
|
||||
use sea_orm::{ActiveModelTrait, EntityTrait, IntoActiveModel, QueryOrder, Set};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::models::_entities::site_settings::{self, ActiveModel, Entity, Model};
|
||||
use crate::{
|
||||
controllers::admin::check_auth,
|
||||
models::_entities::site_settings::{self, ActiveModel, Entity, Model},
|
||||
services::ai,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct SiteSettingsPayload {
|
||||
@@ -42,6 +46,46 @@ pub struct SiteSettingsPayload {
|
||||
pub location: Option<String>,
|
||||
#[serde(default, alias = "techStack")]
|
||||
pub tech_stack: Option<Vec<String>>,
|
||||
#[serde(default, alias = "aiEnabled")]
|
||||
pub ai_enabled: Option<bool>,
|
||||
#[serde(default, alias = "aiProvider")]
|
||||
pub ai_provider: Option<String>,
|
||||
#[serde(default, alias = "aiApiBase")]
|
||||
pub ai_api_base: Option<String>,
|
||||
#[serde(default, alias = "aiApiKey")]
|
||||
pub ai_api_key: Option<String>,
|
||||
#[serde(default, alias = "aiChatModel")]
|
||||
pub ai_chat_model: Option<String>,
|
||||
#[serde(default, alias = "aiEmbeddingModel")]
|
||||
pub ai_embedding_model: Option<String>,
|
||||
#[serde(default, alias = "aiSystemPrompt")]
|
||||
pub ai_system_prompt: Option<String>,
|
||||
#[serde(default, alias = "aiTopK")]
|
||||
pub ai_top_k: Option<i32>,
|
||||
#[serde(default, alias = "aiChunkSize")]
|
||||
pub ai_chunk_size: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct PublicSiteSettingsResponse {
|
||||
pub id: i32,
|
||||
pub site_name: Option<String>,
|
||||
pub site_short_name: Option<String>,
|
||||
pub site_url: Option<String>,
|
||||
pub site_title: Option<String>,
|
||||
pub site_description: Option<String>,
|
||||
pub hero_title: Option<String>,
|
||||
pub hero_subtitle: Option<String>,
|
||||
pub owner_name: Option<String>,
|
||||
pub owner_title: Option<String>,
|
||||
pub owner_bio: Option<String>,
|
||||
pub owner_avatar_url: Option<String>,
|
||||
pub social_github: Option<String>,
|
||||
pub social_twitter: Option<String>,
|
||||
pub social_email: Option<String>,
|
||||
pub location: Option<String>,
|
||||
pub tech_stack: Option<serde_json::Value>,
|
||||
pub ai_enabled: bool,
|
||||
}
|
||||
|
||||
fn normalize_optional_string(value: Option<String>) -> Option<String> {
|
||||
@@ -55,6 +99,10 @@ fn normalize_optional_string(value: Option<String>) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_optional_int(value: Option<i32>, min: i32, max: i32) -> Option<i32> {
|
||||
value.map(|item| item.clamp(min, max))
|
||||
}
|
||||
|
||||
impl SiteSettingsPayload {
|
||||
fn apply(self, item: &mut ActiveModel) {
|
||||
if let Some(site_name) = self.site_name {
|
||||
@@ -105,6 +153,33 @@ impl SiteSettingsPayload {
|
||||
if let Some(tech_stack) = self.tech_stack {
|
||||
item.tech_stack = Set(Some(serde_json::json!(tech_stack)));
|
||||
}
|
||||
if let Some(ai_enabled) = self.ai_enabled {
|
||||
item.ai_enabled = Set(Some(ai_enabled));
|
||||
}
|
||||
if let Some(ai_provider) = self.ai_provider {
|
||||
item.ai_provider = Set(normalize_optional_string(Some(ai_provider)));
|
||||
}
|
||||
if let Some(ai_api_base) = self.ai_api_base {
|
||||
item.ai_api_base = Set(normalize_optional_string(Some(ai_api_base)));
|
||||
}
|
||||
if let Some(ai_api_key) = self.ai_api_key {
|
||||
item.ai_api_key = Set(normalize_optional_string(Some(ai_api_key)));
|
||||
}
|
||||
if let Some(ai_chat_model) = self.ai_chat_model {
|
||||
item.ai_chat_model = Set(normalize_optional_string(Some(ai_chat_model)));
|
||||
}
|
||||
if let Some(ai_embedding_model) = self.ai_embedding_model {
|
||||
item.ai_embedding_model = Set(normalize_optional_string(Some(ai_embedding_model)));
|
||||
}
|
||||
if let Some(ai_system_prompt) = self.ai_system_prompt {
|
||||
item.ai_system_prompt = Set(normalize_optional_string(Some(ai_system_prompt)));
|
||||
}
|
||||
if self.ai_top_k.is_some() {
|
||||
item.ai_top_k = Set(normalize_optional_int(self.ai_top_k, 1, 12));
|
||||
}
|
||||
if self.ai_chunk_size.is_some() {
|
||||
item.ai_chunk_size = Set(normalize_optional_int(self.ai_chunk_size, 400, 4000));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,10 +209,22 @@ fn default_payload() -> SiteSettingsPayload {
|
||||
"Tailwind CSS".to_string(),
|
||||
"TypeScript".to_string(),
|
||||
]),
|
||||
ai_enabled: Some(false),
|
||||
ai_provider: Some(ai::provider_name(None)),
|
||||
ai_api_base: Some(ai::default_api_base().to_string()),
|
||||
ai_api_key: Some(ai::default_api_key().to_string()),
|
||||
ai_chat_model: Some(ai::default_chat_model().to_string()),
|
||||
ai_embedding_model: Some(ai::local_embedding_label().to_string()),
|
||||
ai_system_prompt: Some(
|
||||
"你是这个博客的站内 AI 助手。请优先基于提供的上下文回答,答案要准确、简洁、实用;如果上下文不足,请明确说明。"
|
||||
.to_string(),
|
||||
),
|
||||
ai_top_k: Some(4),
|
||||
ai_chunk_size: Some(1200),
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_current(ctx: &AppContext) -> Result<Model> {
|
||||
pub(crate) async fn load_current(ctx: &AppContext) -> Result<Model> {
|
||||
if let Some(settings) = Entity::find()
|
||||
.order_by_asc(site_settings::Column::Id)
|
||||
.one(&ctx.db)
|
||||
@@ -154,9 +241,32 @@ async fn load_current(ctx: &AppContext) -> Result<Model> {
|
||||
Ok(item.insert(&ctx.db).await?)
|
||||
}
|
||||
|
||||
fn public_response(model: Model) -> PublicSiteSettingsResponse {
|
||||
PublicSiteSettingsResponse {
|
||||
id: model.id,
|
||||
site_name: model.site_name,
|
||||
site_short_name: model.site_short_name,
|
||||
site_url: model.site_url,
|
||||
site_title: model.site_title,
|
||||
site_description: model.site_description,
|
||||
hero_title: model.hero_title,
|
||||
hero_subtitle: model.hero_subtitle,
|
||||
owner_name: model.owner_name,
|
||||
owner_title: model.owner_title,
|
||||
owner_bio: model.owner_bio,
|
||||
owner_avatar_url: model.owner_avatar_url,
|
||||
social_github: model.social_github,
|
||||
social_twitter: model.social_twitter,
|
||||
social_email: model.social_email,
|
||||
location: model.location,
|
||||
tech_stack: model.tech_stack,
|
||||
ai_enabled: model.ai_enabled.unwrap_or(false),
|
||||
}
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn show(State(ctx): State<AppContext>) -> Result<Response> {
|
||||
format::json(load_current(&ctx).await?)
|
||||
format::json(public_response(load_current(&ctx).await?))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
@@ -164,10 +274,13 @@ pub async fn update(
|
||||
State(ctx): State<AppContext>,
|
||||
Json(params): Json<SiteSettingsPayload>,
|
||||
) -> Result<Response> {
|
||||
check_auth()?;
|
||||
|
||||
let current = load_current(&ctx).await?;
|
||||
let mut item = current.into_active_model();
|
||||
params.apply(&mut item);
|
||||
format::json(item.update(&ctx.db).await?)
|
||||
let updated = item.update(&ctx.db).await?;
|
||||
format::json(public_response(updated))
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
|
||||
@@ -48,15 +48,38 @@ pub async fn update(
|
||||
Json(params): Json<Params>,
|
||||
) -> Result<Response> {
|
||||
let item = load_item(&ctx, id).await?;
|
||||
let previous_name = item.name.clone();
|
||||
let previous_slug = item.slug.clone();
|
||||
let next_name = params
|
||||
.name
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
|
||||
if let Some(next_name) = next_name {
|
||||
if previous_name
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
!= Some(next_name)
|
||||
{
|
||||
content::rewrite_tag_references(previous_name.as_deref(), &previous_slug, Some(next_name))?;
|
||||
}
|
||||
}
|
||||
|
||||
let mut item = item.into_active_model();
|
||||
params.update(&mut item);
|
||||
let item = item.update(&ctx.db).await?;
|
||||
content::sync_markdown_posts(&ctx).await?;
|
||||
format::json(item)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn remove(Path(id): Path<i32>, State(ctx): State<AppContext>) -> Result<Response> {
|
||||
load_item(&ctx, id).await?.delete(&ctx.db).await?;
|
||||
let item = load_item(&ctx, id).await?;
|
||||
content::rewrite_tag_references(item.name.as_deref(), &item.slug, None)?;
|
||||
item.delete(&ctx.db).await?;
|
||||
content::sync_markdown_posts(&ctx).await?;
|
||||
format::empty()
|
||||
}
|
||||
|
||||
|
||||
@@ -19,3 +19,12 @@
|
||||
- "Svelte"
|
||||
- "Tailwind CSS"
|
||||
- "TypeScript"
|
||||
ai_enabled: false
|
||||
ai_provider: "newapi"
|
||||
ai_api_base: "http://localhost:8317/v1"
|
||||
ai_api_key: "your-api-key-1"
|
||||
ai_chat_model: "gpt-5.4"
|
||||
ai_embedding_model: "fastembed / local all-MiniLM-L6-v2"
|
||||
ai_system_prompt: "你是这个博客的站内 AI 助手。请优先基于提供的上下文回答,答案要准确、简洁、实用;如果上下文不足,请明确说明。"
|
||||
ai_top_k: 4
|
||||
ai_chunk_size: 1200
|
||||
|
||||
@@ -56,6 +56,13 @@ fn is_blank(value: &Option<String>) -> bool {
|
||||
value.as_deref().map(str::trim).unwrap_or("").is_empty()
|
||||
}
|
||||
|
||||
fn matches_legacy_ai_defaults(settings: &site_settings::Model) -> bool {
|
||||
settings.ai_provider.as_deref().map(str::trim) == Some("openai-compatible")
|
||||
&& settings.ai_api_base.as_deref().map(str::trim) == Some("https://api.openai.com/v1")
|
||||
&& settings.ai_chat_model.as_deref().map(str::trim) == Some("gpt-4.1-mini")
|
||||
&& is_blank(&settings.ai_api_key)
|
||||
}
|
||||
|
||||
async fn sync_site_settings(ctx: &AppContext, base: &Path) -> Result<()> {
|
||||
let rows = read_fixture_rows(base, "site_settings.yaml");
|
||||
let Some(seed) = rows.first() else {
|
||||
@@ -81,6 +88,7 @@ async fn sync_site_settings(ctx: &AppContext, base: &Path) -> Result<()> {
|
||||
|
||||
if let Some(existing) = existing {
|
||||
let mut model = existing.clone().into_active_model();
|
||||
let should_upgrade_legacy_ai_defaults = matches_legacy_ai_defaults(&existing);
|
||||
|
||||
if is_blank(&existing.site_name) {
|
||||
model.site_name = Set(as_optional_string(&seed["site_name"]));
|
||||
@@ -130,6 +138,39 @@ async fn sync_site_settings(ctx: &AppContext, base: &Path) -> Result<()> {
|
||||
if existing.tech_stack.is_none() {
|
||||
model.tech_stack = Set(tech_stack);
|
||||
}
|
||||
if existing.ai_enabled.is_none() {
|
||||
model.ai_enabled = Set(seed["ai_enabled"].as_bool());
|
||||
}
|
||||
if should_upgrade_legacy_ai_defaults {
|
||||
model.ai_provider = Set(as_optional_string(&seed["ai_provider"]));
|
||||
model.ai_api_base = Set(as_optional_string(&seed["ai_api_base"]));
|
||||
model.ai_api_key = Set(as_optional_string(&seed["ai_api_key"]));
|
||||
model.ai_chat_model = Set(as_optional_string(&seed["ai_chat_model"]));
|
||||
}
|
||||
if is_blank(&existing.ai_provider) {
|
||||
model.ai_provider = Set(as_optional_string(&seed["ai_provider"]));
|
||||
}
|
||||
if is_blank(&existing.ai_api_base) {
|
||||
model.ai_api_base = Set(as_optional_string(&seed["ai_api_base"]));
|
||||
}
|
||||
if is_blank(&existing.ai_api_key) {
|
||||
model.ai_api_key = Set(as_optional_string(&seed["ai_api_key"]));
|
||||
}
|
||||
if is_blank(&existing.ai_chat_model) {
|
||||
model.ai_chat_model = Set(as_optional_string(&seed["ai_chat_model"]));
|
||||
}
|
||||
if is_blank(&existing.ai_embedding_model) {
|
||||
model.ai_embedding_model = Set(as_optional_string(&seed["ai_embedding_model"]));
|
||||
}
|
||||
if is_blank(&existing.ai_system_prompt) {
|
||||
model.ai_system_prompt = Set(as_optional_string(&seed["ai_system_prompt"]));
|
||||
}
|
||||
if existing.ai_top_k.is_none() {
|
||||
model.ai_top_k = Set(seed["ai_top_k"].as_i64().map(|value| value as i32));
|
||||
}
|
||||
if existing.ai_chunk_size.is_none() {
|
||||
model.ai_chunk_size = Set(seed["ai_chunk_size"].as_i64().map(|value| value as i32));
|
||||
}
|
||||
|
||||
let _ = model.update(&ctx.db).await;
|
||||
return Ok(());
|
||||
@@ -153,6 +194,15 @@ async fn sync_site_settings(ctx: &AppContext, base: &Path) -> Result<()> {
|
||||
social_email: Set(as_optional_string(&seed["social_email"])),
|
||||
location: Set(as_optional_string(&seed["location"])),
|
||||
tech_stack: Set(tech_stack),
|
||||
ai_enabled: Set(seed["ai_enabled"].as_bool()),
|
||||
ai_provider: Set(as_optional_string(&seed["ai_provider"])),
|
||||
ai_api_base: Set(as_optional_string(&seed["ai_api_base"])),
|
||||
ai_api_key: Set(as_optional_string(&seed["ai_api_key"])),
|
||||
ai_chat_model: Set(as_optional_string(&seed["ai_chat_model"])),
|
||||
ai_embedding_model: Set(as_optional_string(&seed["ai_embedding_model"])),
|
||||
ai_system_prompt: Set(as_optional_string(&seed["ai_system_prompt"])),
|
||||
ai_top_k: Set(seed["ai_top_k"].as_i64().map(|value| value as i32)),
|
||||
ai_chunk_size: Set(seed["ai_chunk_size"].as_i64().map(|value| value as i32)),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
|
||||
28
backend/src/models/_entities/ai_chunks.rs
Normal file
28
backend/src/models/_entities/ai_chunks.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
//! `SeaORM` Entity, manually maintained
|
||||
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)]
|
||||
#[sea_orm(table_name = "ai_chunks")]
|
||||
pub struct Model {
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
pub updated_at: DateTimeWithTimeZone,
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i32,
|
||||
pub source_slug: String,
|
||||
pub source_title: Option<String>,
|
||||
pub source_path: Option<String>,
|
||||
pub source_type: String,
|
||||
pub chunk_index: i32,
|
||||
#[sea_orm(column_type = "Text")]
|
||||
pub content: String,
|
||||
pub content_preview: Option<String>,
|
||||
pub embedding: Option<String>,
|
||||
pub word_count: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -17,7 +17,11 @@ pub struct Model {
|
||||
pub avatar: Option<String>,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub content: Option<String>,
|
||||
pub scope: String,
|
||||
pub paragraph_key: Option<String>,
|
||||
pub paragraph_excerpt: Option<String>,
|
||||
pub reply_to: Option<Uuid>,
|
||||
pub reply_to_comment_id: Option<i32>,
|
||||
pub approved: Option<bool>,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.10
|
||||
|
||||
pub mod ai_chunks;
|
||||
pub mod prelude;
|
||||
|
||||
pub mod categories;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.10
|
||||
|
||||
pub use super::ai_chunks::Entity as AiChunks;
|
||||
pub use super::categories::Entity as Categories;
|
||||
pub use super::comments::Entity as Comments;
|
||||
pub use super::friend_links::Entity as FriendLinks;
|
||||
|
||||
@@ -28,6 +28,18 @@ pub struct Model {
|
||||
pub location: Option<String>,
|
||||
#[sea_orm(column_type = "JsonBinary", nullable)]
|
||||
pub tech_stack: Option<Json>,
|
||||
pub ai_enabled: Option<bool>,
|
||||
pub ai_provider: Option<String>,
|
||||
pub ai_api_base: Option<String>,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub ai_api_key: Option<String>,
|
||||
pub ai_chat_model: Option<String>,
|
||||
pub ai_embedding_model: Option<String>,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub ai_system_prompt: Option<String>,
|
||||
pub ai_top_k: Option<i32>,
|
||||
pub ai_chunk_size: Option<i32>,
|
||||
pub ai_last_indexed_at: Option<DateTimeWithTimeZone>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
|
||||
3
backend/src/models/ai_chunks.rs
Normal file
3
backend/src/models/ai_chunks.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub use super::_entities::ai_chunks::{ActiveModel, Entity, Model};
|
||||
|
||||
pub type AiChunks = Entity;
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod _entities;
|
||||
pub mod ai_chunks;
|
||||
pub mod categories;
|
||||
pub mod comments;
|
||||
pub mod friend_links;
|
||||
|
||||
993
backend/src/services/ai.rs
Normal file
993
backend/src/services/ai.rs
Normal file
@@ -0,0 +1,993 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use fastembed::{
|
||||
InitOptionsUserDefined, Pooling, TextEmbedding, TokenizerFiles, UserDefinedEmbeddingModel,
|
||||
};
|
||||
use loco_rs::prelude::*;
|
||||
use reqwest::Client;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ConnectionTrait, DbBackend, EntityTrait, FromQueryResult, IntoActiveModel,
|
||||
PaginatorTrait, QueryOrder, Set, Statement,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use crate::{
|
||||
models::_entities::{ai_chunks, site_settings},
|
||||
services::content,
|
||||
};
|
||||
|
||||
const DEFAULT_AI_PROVIDER: &str = "newapi";
|
||||
const DEFAULT_AI_API_BASE: &str = "http://localhost:8317/v1";
|
||||
const DEFAULT_AI_API_KEY: &str = "your-api-key-1";
|
||||
const DEFAULT_CHAT_MODEL: &str = "gpt-5.4";
|
||||
const DEFAULT_REASONING_EFFORT: &str = "medium";
|
||||
const DEFAULT_DISABLE_RESPONSE_STORAGE: bool = true;
|
||||
const DEFAULT_TOP_K: usize = 4;
|
||||
const DEFAULT_CHUNK_SIZE: usize = 1200;
|
||||
const DEFAULT_SYSTEM_PROMPT: &str =
|
||||
"你是这个博客的站内 AI 助手。请严格基于提供的博客上下文回答,优先给出准确结论,再补充细节;如果上下文不足,请明确说明。";
|
||||
const EMBEDDING_BATCH_SIZE: usize = 32;
|
||||
const EMBEDDING_DIMENSION: usize = 384;
|
||||
const LOCAL_EMBEDDING_MODEL_LABEL: &str = "fastembed / local all-MiniLM-L6-v2";
|
||||
const LOCAL_EMBEDDING_CACHE_DIR: &str = "storage/ai_embedding_models/all-minilm-l6-v2";
|
||||
const LOCAL_EMBEDDING_BASE_URL: &str =
|
||||
"https://huggingface.co/Qdrant/all-MiniLM-L6-v2-onnx/resolve/main";
|
||||
const LOCAL_EMBEDDING_FILES: [&str; 5] = [
|
||||
"model.onnx",
|
||||
"tokenizer.json",
|
||||
"config.json",
|
||||
"special_tokens_map.json",
|
||||
"tokenizer_config.json",
|
||||
];
|
||||
|
||||
static TEXT_EMBEDDING_MODEL: OnceLock<Mutex<TextEmbedding>> = OnceLock::new();
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct AiRuntimeSettings {
|
||||
raw: site_settings::Model,
|
||||
provider: String,
|
||||
api_base: Option<String>,
|
||||
api_key: Option<String>,
|
||||
chat_model: String,
|
||||
system_prompt: String,
|
||||
top_k: usize,
|
||||
chunk_size: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct ChunkDraft {
|
||||
source_slug: String,
|
||||
source_title: Option<String>,
|
||||
source_path: Option<String>,
|
||||
source_type: String,
|
||||
chunk_index: i32,
|
||||
content: String,
|
||||
content_preview: Option<String>,
|
||||
word_count: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct ScoredChunk {
|
||||
score: f64,
|
||||
row: ai_chunks::Model,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, FromQueryResult)]
|
||||
struct SimilarChunkRow {
|
||||
source_slug: String,
|
||||
source_title: Option<String>,
|
||||
chunk_index: i32,
|
||||
content: String,
|
||||
content_preview: Option<String>,
|
||||
word_count: Option<i32>,
|
||||
score: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum EmbeddingKind {
|
||||
Passage,
|
||||
Query,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct AiSource {
|
||||
pub slug: String,
|
||||
pub title: String,
|
||||
pub excerpt: String,
|
||||
pub score: f64,
|
||||
pub chunk_index: i32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AiAnswer {
|
||||
pub answer: String,
|
||||
pub sources: Vec<AiSource>,
|
||||
pub indexed_chunks: usize,
|
||||
pub last_indexed_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct AiProviderRequest {
|
||||
pub(crate) provider: String,
|
||||
pub(crate) api_base: String,
|
||||
pub(crate) api_key: String,
|
||||
pub(crate) chat_model: String,
|
||||
pub(crate) system_prompt: String,
|
||||
pub(crate) prompt: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct PreparedAiAnswer {
|
||||
pub(crate) question: String,
|
||||
pub(crate) provider_request: Option<AiProviderRequest>,
|
||||
pub(crate) immediate_answer: Option<String>,
|
||||
pub(crate) sources: Vec<AiSource>,
|
||||
pub(crate) indexed_chunks: usize,
|
||||
pub(crate) last_indexed_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AiIndexSummary {
|
||||
pub indexed_chunks: usize,
|
||||
pub last_indexed_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
fn trim_to_option(value: Option<String>) -> Option<String> {
|
||||
value.and_then(|item| {
|
||||
let trimmed = item.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn preview_text(content: &str, limit: usize) -> Option<String> {
|
||||
let flattened = content
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
if flattened.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let preview = flattened.chars().take(limit).collect::<String>();
|
||||
Some(preview)
|
||||
}
|
||||
|
||||
fn build_endpoint(api_base: &str, path: &str) -> String {
|
||||
format!(
|
||||
"{}/{}",
|
||||
api_base.trim_end_matches('/'),
|
||||
path.trim_start_matches('/')
|
||||
)
|
||||
}
|
||||
|
||||
fn local_embedding_dir() -> PathBuf {
|
||||
PathBuf::from(LOCAL_EMBEDDING_CACHE_DIR)
|
||||
}
|
||||
|
||||
fn download_embedding_file(
|
||||
client: &reqwest::blocking::Client,
|
||||
directory: &Path,
|
||||
file_name: &str,
|
||||
) -> Result<()> {
|
||||
let target_path = directory.join(file_name);
|
||||
if target_path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let url = format!("{LOCAL_EMBEDDING_BASE_URL}/{file_name}");
|
||||
let bytes = client
|
||||
.get(url)
|
||||
.send()
|
||||
.and_then(reqwest::blocking::Response::error_for_status)
|
||||
.map_err(|error| Error::BadRequest(format!("下载本地 embedding 文件失败: {error}")))?
|
||||
.bytes()
|
||||
.map_err(|error| Error::BadRequest(format!("读取本地 embedding 文件失败: {error}")))?;
|
||||
|
||||
fs::write(&target_path, &bytes)
|
||||
.map_err(|error| Error::BadRequest(format!("写入本地 embedding 文件失败: {error}")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_local_embedding_files() -> Result<PathBuf> {
|
||||
let directory = local_embedding_dir();
|
||||
fs::create_dir_all(&directory)
|
||||
.map_err(|error| Error::BadRequest(format!("创建本地 embedding 目录失败: {error}")))?;
|
||||
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
Error::BadRequest(format!("创建本地 embedding 下载客户端失败: {error}"))
|
||||
})?;
|
||||
|
||||
for file_name in LOCAL_EMBEDDING_FILES {
|
||||
download_embedding_file(&client, &directory, file_name)?;
|
||||
}
|
||||
|
||||
Ok(directory)
|
||||
}
|
||||
|
||||
fn load_local_embedding_model() -> Result<TextEmbedding> {
|
||||
let directory = ensure_local_embedding_files()?;
|
||||
let tokenizer_files = TokenizerFiles {
|
||||
tokenizer_file: fs::read(directory.join("tokenizer.json"))
|
||||
.map_err(|error| Error::BadRequest(format!("读取 tokenizer.json 失败: {error}")))?,
|
||||
config_file: fs::read(directory.join("config.json"))
|
||||
.map_err(|error| Error::BadRequest(format!("读取 config.json 失败: {error}")))?,
|
||||
special_tokens_map_file: fs::read(directory.join("special_tokens_map.json")).map_err(
|
||||
|error| Error::BadRequest(format!("读取 special_tokens_map.json 失败: {error}")),
|
||||
)?,
|
||||
tokenizer_config_file: fs::read(directory.join("tokenizer_config.json")).map_err(
|
||||
|error| Error::BadRequest(format!("读取 tokenizer_config.json 失败: {error}")),
|
||||
)?,
|
||||
};
|
||||
|
||||
let model = UserDefinedEmbeddingModel::new(
|
||||
fs::read(directory.join("model.onnx"))
|
||||
.map_err(|error| Error::BadRequest(format!("读取 model.onnx 失败: {error}")))?,
|
||||
tokenizer_files,
|
||||
)
|
||||
.with_pooling(Pooling::Mean);
|
||||
|
||||
TextEmbedding::try_new_from_user_defined(model, InitOptionsUserDefined::default())
|
||||
.map_err(|error| Error::BadRequest(format!("本地 embedding 模型初始化失败: {error}")))
|
||||
}
|
||||
|
||||
fn local_embedding_engine() -> Result<&'static Mutex<TextEmbedding>> {
|
||||
if let Some(model) = TEXT_EMBEDDING_MODEL.get() {
|
||||
return Ok(model);
|
||||
}
|
||||
|
||||
let model = load_local_embedding_model()?;
|
||||
|
||||
let _ = TEXT_EMBEDDING_MODEL.set(Mutex::new(model));
|
||||
|
||||
TEXT_EMBEDDING_MODEL
|
||||
.get()
|
||||
.ok_or_else(|| Error::BadRequest("本地 embedding 模型未能成功缓存".to_string()))
|
||||
}
|
||||
|
||||
fn vector_literal(embedding: &[f64]) -> Result<String> {
|
||||
if embedding.len() != EMBEDDING_DIMENSION {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"embedding 维度异常,期望 {EMBEDDING_DIMENSION},实际 {}",
|
||||
embedding.len()
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(format!(
|
||||
"[{}]",
|
||||
embedding
|
||||
.iter()
|
||||
.map(|value| value.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
))
|
||||
}
|
||||
|
||||
fn prepare_embedding_text(kind: EmbeddingKind, text: &str) -> String {
|
||||
match kind {
|
||||
EmbeddingKind::Passage | EmbeddingKind::Query => text.trim().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn split_long_text(text: &str, chunk_size: usize) -> Vec<String> {
|
||||
let mut parts = Vec::new();
|
||||
let mut current = String::new();
|
||||
|
||||
for line in text.lines() {
|
||||
let candidate = if current.is_empty() {
|
||||
line.to_string()
|
||||
} else {
|
||||
format!("{current}\n{line}")
|
||||
};
|
||||
|
||||
if candidate.chars().count() > chunk_size && !current.is_empty() {
|
||||
parts.push(current.trim().to_string());
|
||||
current = line.to_string();
|
||||
} else {
|
||||
current = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
if !current.trim().is_empty() {
|
||||
parts.push(current.trim().to_string());
|
||||
}
|
||||
|
||||
parts
|
||||
}
|
||||
|
||||
fn build_chunks(posts: &[content::MarkdownPost], chunk_size: usize) -> Vec<ChunkDraft> {
|
||||
let mut chunks = Vec::new();
|
||||
|
||||
for post in posts.iter().filter(|post| post.published) {
|
||||
let mut sections = Vec::new();
|
||||
sections.push(format!("# {}", post.title));
|
||||
if let Some(description) = post
|
||||
.description
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
sections.push(description.trim().to_string());
|
||||
}
|
||||
sections.push(post.content.trim().to_string());
|
||||
|
||||
let source_text = sections
|
||||
.into_iter()
|
||||
.filter(|item| !item.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n");
|
||||
|
||||
let paragraphs = source_text
|
||||
.split("\n\n")
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut buffer = String::new();
|
||||
let mut chunk_index = 0_i32;
|
||||
|
||||
for paragraph in paragraphs {
|
||||
if paragraph.chars().count() > chunk_size {
|
||||
if !buffer.trim().is_empty() {
|
||||
chunks.push(ChunkDraft {
|
||||
source_slug: post.slug.clone(),
|
||||
source_title: Some(post.title.clone()),
|
||||
source_path: Some(post.file_path.clone()),
|
||||
source_type: "post".to_string(),
|
||||
chunk_index,
|
||||
content: buffer.trim().to_string(),
|
||||
content_preview: preview_text(&buffer, 180),
|
||||
word_count: Some(buffer.split_whitespace().count() as i32),
|
||||
});
|
||||
chunk_index += 1;
|
||||
buffer.clear();
|
||||
}
|
||||
|
||||
for part in split_long_text(paragraph, chunk_size) {
|
||||
if part.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
chunks.push(ChunkDraft {
|
||||
source_slug: post.slug.clone(),
|
||||
source_title: Some(post.title.clone()),
|
||||
source_path: Some(post.file_path.clone()),
|
||||
source_type: "post".to_string(),
|
||||
chunk_index,
|
||||
content_preview: preview_text(&part, 180),
|
||||
word_count: Some(part.split_whitespace().count() as i32),
|
||||
content: part,
|
||||
});
|
||||
chunk_index += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let candidate = if buffer.is_empty() {
|
||||
paragraph.to_string()
|
||||
} else {
|
||||
format!("{buffer}\n\n{paragraph}")
|
||||
};
|
||||
|
||||
if candidate.chars().count() > chunk_size && !buffer.trim().is_empty() {
|
||||
chunks.push(ChunkDraft {
|
||||
source_slug: post.slug.clone(),
|
||||
source_title: Some(post.title.clone()),
|
||||
source_path: Some(post.file_path.clone()),
|
||||
source_type: "post".to_string(),
|
||||
chunk_index,
|
||||
content_preview: preview_text(&buffer, 180),
|
||||
word_count: Some(buffer.split_whitespace().count() as i32),
|
||||
content: buffer.trim().to_string(),
|
||||
});
|
||||
chunk_index += 1;
|
||||
buffer = paragraph.to_string();
|
||||
} else {
|
||||
buffer = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
if !buffer.trim().is_empty() {
|
||||
chunks.push(ChunkDraft {
|
||||
source_slug: post.slug.clone(),
|
||||
source_title: Some(post.title.clone()),
|
||||
source_path: Some(post.file_path.clone()),
|
||||
source_type: "post".to_string(),
|
||||
chunk_index,
|
||||
content_preview: preview_text(&buffer, 180),
|
||||
word_count: Some(buffer.split_whitespace().count() as i32),
|
||||
content: buffer.trim().to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
chunks
|
||||
}
|
||||
|
||||
async fn request_json(client: &Client, url: &str, api_key: &str, payload: Value) -> Result<Value> {
|
||||
let response = client
|
||||
.post(url)
|
||||
.bearer_auth(api_key)
|
||||
.header("Accept", "application/json")
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| Error::BadRequest(format!("AI request failed: {error}")))?;
|
||||
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|error| Error::BadRequest(format!("AI response read failed: {error}")))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"AI provider returned {status}: {body}"
|
||||
)));
|
||||
}
|
||||
|
||||
serde_json::from_str(&body)
|
||||
.map_err(|error| Error::BadRequest(format!("AI response parse failed: {error}")))
|
||||
}
|
||||
|
||||
fn provider_uses_responses(provider: &str) -> bool {
|
||||
provider.eq_ignore_ascii_case("newapi")
|
||||
}
|
||||
|
||||
async fn embed_texts_locally(inputs: Vec<String>, kind: EmbeddingKind) -> Result<Vec<Vec<f64>>> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let model = local_embedding_engine()?;
|
||||
let prepared = inputs
|
||||
.iter()
|
||||
.map(|item| prepare_embedding_text(kind, item))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut guard = model.lock().map_err(|_| {
|
||||
Error::BadRequest("本地 embedding 模型当前不可用,请稍后重试".to_string())
|
||||
})?;
|
||||
|
||||
let embeddings = guard
|
||||
.embed(prepared, Some(EMBEDDING_BATCH_SIZE))
|
||||
.map_err(|error| Error::BadRequest(format!("本地 embedding 生成失败: {error}")))?;
|
||||
|
||||
Ok(embeddings
|
||||
.into_iter()
|
||||
.map(|embedding| embedding.into_iter().map(f64::from).collect::<Vec<_>>())
|
||||
.collect::<Vec<_>>())
|
||||
})
|
||||
.await
|
||||
.map_err(|error| Error::BadRequest(format!("本地 embedding 任务执行失败: {error}")))?
|
||||
}
|
||||
|
||||
fn extract_message_content(value: &Value) -> Option<String> {
|
||||
if let Some(content) = value
|
||||
.get("choices")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|choices| choices.first())
|
||||
.and_then(|choice| choice.get("message"))
|
||||
.and_then(|message| message.get("content"))
|
||||
{
|
||||
if let Some(text) = content.as_str() {
|
||||
return Some(text.trim().to_string());
|
||||
}
|
||||
|
||||
if let Some(parts) = content.as_array() {
|
||||
let merged = parts
|
||||
.iter()
|
||||
.filter_map(|part| part.get("text").and_then(Value::as_str))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
if !merged.trim().is_empty() {
|
||||
return Some(merged.trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn merge_text_segments(parts: Vec<String>) -> Option<String> {
|
||||
let merged = parts
|
||||
.into_iter()
|
||||
.filter_map(|part| {
|
||||
let trimmed = part.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed)
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
if merged.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(merged)
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_response_output(value: &Value) -> Option<String> {
|
||||
if let Some(text) = value.get("output_text").and_then(Value::as_str) {
|
||||
let trimmed = text.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let output_items = value.get("output").and_then(Value::as_array)?;
|
||||
let mut segments = Vec::new();
|
||||
|
||||
for item in output_items {
|
||||
let Some(content_items) = item.get("content").and_then(Value::as_array) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
for content in content_items {
|
||||
if let Some(text) = content.get("text").and_then(Value::as_str) {
|
||||
segments.push(text.to_string());
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(text) = content
|
||||
.get("output_text")
|
||||
.and_then(|output_text| output_text.get("text"))
|
||||
.and_then(Value::as_str)
|
||||
{
|
||||
segments.push(text.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
merge_text_segments(segments)
|
||||
}
|
||||
|
||||
fn build_chat_prompt(question: &str, matches: &[ScoredChunk]) -> String {
|
||||
let context_blocks = matches
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, item)| {
|
||||
format!(
|
||||
"[资料 {}]\n标题: {}\nSlug: {}\n相似度: {:.4}\n内容:\n{}",
|
||||
index + 1,
|
||||
item.row
|
||||
.source_title
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or("未命名内容"),
|
||||
item.row.source_slug,
|
||||
item.score,
|
||||
item.row.content
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n");
|
||||
|
||||
format!(
|
||||
"请仅根据下面提供的资料回答用户问题。\n\
|
||||
如果资料不足以支撑结论,请直接说明“我在当前博客资料里没有找到足够信息”。\n\
|
||||
回答要求:\n\
|
||||
1. 使用中文。\n\
|
||||
2. 使用 Markdown 输出,必要时用短列表或小标题,不要输出 HTML。\n\
|
||||
3. 先给直接结论,再补充关键点,整体尽量精炼。\n\
|
||||
4. 不要编造未在资料中出现的事实。\n\
|
||||
5. 如果回答引用了具体资料,可自然地提及文章标题。\n\n\
|
||||
用户问题:{question}\n\n\
|
||||
可用资料:\n{context_blocks}"
|
||||
)
|
||||
}
|
||||
|
||||
fn build_sources(matches: &[ScoredChunk]) -> Vec<AiSource> {
|
||||
matches
|
||||
.iter()
|
||||
.map(|item| AiSource {
|
||||
slug: item.row.source_slug.clone(),
|
||||
title: item
|
||||
.row
|
||||
.source_title
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or("未命名内容")
|
||||
.to_string(),
|
||||
excerpt: item
|
||||
.row
|
||||
.content_preview
|
||||
.clone()
|
||||
.unwrap_or_else(|| preview_text(&item.row.content, 180).unwrap_or_default()),
|
||||
score: (item.score * 10000.0).round() / 10000.0,
|
||||
chunk_index: item.row.chunk_index,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
|
||||
pub(crate) fn build_provider_payload(request: &AiProviderRequest, stream: bool) -> Value {
|
||||
if provider_uses_responses(&request.provider) {
|
||||
json!({
|
||||
"model": request.chat_model,
|
||||
"input": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": [
|
||||
{
|
||||
"type": "input_text",
|
||||
"text": request.system_prompt
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "input_text",
|
||||
"text": request.prompt
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"reasoning": {
|
||||
"effort": DEFAULT_REASONING_EFFORT
|
||||
},
|
||||
"max_output_tokens": 520,
|
||||
"store": !DEFAULT_DISABLE_RESPONSE_STORAGE,
|
||||
"stream": stream
|
||||
})
|
||||
} else {
|
||||
json!({
|
||||
"model": request.chat_model,
|
||||
"temperature": 0.2,
|
||||
"stream": stream,
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": request.system_prompt,
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": request.prompt,
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn build_provider_url(request: &AiProviderRequest) -> String {
|
||||
let path = if provider_uses_responses(&request.provider) {
|
||||
"/responses"
|
||||
} else {
|
||||
"/chat/completions"
|
||||
};
|
||||
|
||||
build_endpoint(&request.api_base, path)
|
||||
}
|
||||
|
||||
pub(crate) fn extract_provider_text(value: &Value) -> Option<String> {
|
||||
extract_response_output(value).or_else(|| extract_message_content(value))
|
||||
}
|
||||
|
||||
async fn request_chat_answer(request: &AiProviderRequest) -> Result<String> {
|
||||
let client = Client::new();
|
||||
let response = request_json(
|
||||
&client,
|
||||
&build_provider_url(request),
|
||||
&request.api_key,
|
||||
build_provider_payload(request, false),
|
||||
)
|
||||
.await?;
|
||||
|
||||
extract_provider_text(&response).ok_or_else(|| {
|
||||
Error::BadRequest("AI chat response did not contain readable content".to_string())
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn prepare_answer(ctx: &AppContext, question: &str) -> Result<PreparedAiAnswer> {
|
||||
let trimmed_question = question.trim();
|
||||
if trimmed_question.is_empty() {
|
||||
return Err(Error::BadRequest("问题不能为空".to_string()));
|
||||
}
|
||||
|
||||
let settings = load_runtime_settings(ctx, true).await?;
|
||||
let (matches, indexed_chunks, last_indexed_at) =
|
||||
retrieve_matches(ctx, &settings, trimmed_question).await?;
|
||||
|
||||
if matches.is_empty() {
|
||||
return Ok(PreparedAiAnswer {
|
||||
question: trimmed_question.to_string(),
|
||||
provider_request: None,
|
||||
immediate_answer: Some(
|
||||
"我在当前博客资料里没有找到足够信息。你可以换个更具体的问题,或者先去后台重建一下 AI 索引。"
|
||||
.to_string(),
|
||||
),
|
||||
sources: Vec::new(),
|
||||
indexed_chunks,
|
||||
last_indexed_at,
|
||||
});
|
||||
}
|
||||
|
||||
let sources = build_sources(&matches);
|
||||
let provider_request = match (settings.api_base.clone(), settings.api_key.clone()) {
|
||||
(Some(api_base), Some(api_key)) => Some(AiProviderRequest {
|
||||
provider: settings.provider.clone(),
|
||||
api_base,
|
||||
api_key,
|
||||
chat_model: settings.chat_model.clone(),
|
||||
system_prompt: settings.system_prompt.clone(),
|
||||
prompt: build_chat_prompt(trimmed_question, &matches),
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let immediate_answer = provider_request
|
||||
.is_none()
|
||||
.then(|| retrieval_only_answer(&matches));
|
||||
|
||||
Ok(PreparedAiAnswer {
|
||||
question: trimmed_question.to_string(),
|
||||
provider_request,
|
||||
immediate_answer,
|
||||
sources,
|
||||
indexed_chunks,
|
||||
last_indexed_at,
|
||||
})
|
||||
}
|
||||
|
||||
fn retrieval_only_answer(matches: &[ScoredChunk]) -> String {
|
||||
let summary = matches
|
||||
.iter()
|
||||
.take(3)
|
||||
.map(|item| {
|
||||
let title = item
|
||||
.row
|
||||
.source_title
|
||||
.as_deref()
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
.unwrap_or("未命名内容");
|
||||
let excerpt = item
|
||||
.row
|
||||
.content_preview
|
||||
.clone()
|
||||
.unwrap_or_else(|| preview_text(&item.row.content, 120).unwrap_or_default());
|
||||
|
||||
format!("《{title}》: {excerpt}")
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
format!(
|
||||
"本地知识检索已经完成,但后台还没有配置聊天模型 API,所以我先返回最相关的资料摘要:\n{summary}\n\n\
|
||||
如果你希望得到完整的自然语言回答,请在后台补上聊天模型的 API Base / API Key。"
|
||||
)
|
||||
}
|
||||
|
||||
async fn load_runtime_settings(
|
||||
ctx: &AppContext,
|
||||
require_enabled: bool,
|
||||
) -> Result<AiRuntimeSettings> {
|
||||
let raw = site_settings::Entity::find()
|
||||
.order_by_asc(site_settings::Column::Id)
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.ok_or(Error::NotFound)?;
|
||||
|
||||
if require_enabled && !raw.ai_enabled.unwrap_or(false) {
|
||||
return Err(Error::NotFound);
|
||||
}
|
||||
|
||||
Ok(AiRuntimeSettings {
|
||||
provider: provider_name(raw.ai_provider.as_deref()),
|
||||
api_base: trim_to_option(raw.ai_api_base.clone()),
|
||||
api_key: trim_to_option(raw.ai_api_key.clone()),
|
||||
chat_model: trim_to_option(raw.ai_chat_model.clone())
|
||||
.unwrap_or_else(|| DEFAULT_CHAT_MODEL.to_string()),
|
||||
system_prompt: trim_to_option(raw.ai_system_prompt.clone())
|
||||
.unwrap_or_else(|| DEFAULT_SYSTEM_PROMPT.to_string()),
|
||||
top_k: raw
|
||||
.ai_top_k
|
||||
.map(|value| value.clamp(1, 12) as usize)
|
||||
.unwrap_or(DEFAULT_TOP_K),
|
||||
chunk_size: raw
|
||||
.ai_chunk_size
|
||||
.map(|value| value.clamp(400, 4000) as usize)
|
||||
.unwrap_or(DEFAULT_CHUNK_SIZE),
|
||||
raw,
|
||||
})
|
||||
}
|
||||
|
||||
async fn update_indexed_at(
|
||||
ctx: &AppContext,
|
||||
settings: &site_settings::Model,
|
||||
) -> Result<DateTime<Utc>> {
|
||||
let now = Utc::now();
|
||||
let mut model = settings.clone().into_active_model();
|
||||
model.ai_last_indexed_at = Set(Some(now.into()));
|
||||
let _ = model.update(&ctx.db).await?;
|
||||
Ok(now)
|
||||
}
|
||||
|
||||
async fn retrieve_matches(
|
||||
ctx: &AppContext,
|
||||
settings: &AiRuntimeSettings,
|
||||
question: &str,
|
||||
) -> Result<(Vec<ScoredChunk>, usize, Option<DateTime<Utc>>)> {
|
||||
let mut indexed_chunks = ai_chunks::Entity::find().count(&ctx.db).await? as usize;
|
||||
let mut last_indexed_at = settings.raw.ai_last_indexed_at.map(Into::into);
|
||||
|
||||
if indexed_chunks == 0 {
|
||||
let summary = rebuild_index(ctx).await?;
|
||||
indexed_chunks = summary.indexed_chunks;
|
||||
last_indexed_at = summary.last_indexed_at;
|
||||
}
|
||||
|
||||
if indexed_chunks == 0 {
|
||||
return Ok((Vec::new(), 0, last_indexed_at));
|
||||
}
|
||||
|
||||
let question_embedding =
|
||||
embed_texts_locally(vec![question.trim().to_string()], EmbeddingKind::Query)
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
.unwrap_or_default();
|
||||
let query_vector = vector_literal(&question_embedding)?;
|
||||
|
||||
let statement = Statement::from_sql_and_values(
|
||||
DbBackend::Postgres,
|
||||
r#"
|
||||
SELECT
|
||||
source_slug,
|
||||
source_title,
|
||||
chunk_index,
|
||||
content,
|
||||
content_preview,
|
||||
word_count,
|
||||
(1 - (embedding <=> $1::vector))::float8 AS score
|
||||
FROM ai_chunks
|
||||
WHERE embedding IS NOT NULL
|
||||
ORDER BY embedding <=> $1::vector
|
||||
LIMIT $2
|
||||
"#,
|
||||
[query_vector.into(), (settings.top_k as i64).into()],
|
||||
);
|
||||
|
||||
let matches = SimilarChunkRow::find_by_statement(statement)
|
||||
.all(&ctx.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|row| ScoredChunk {
|
||||
score: row.score,
|
||||
row: ai_chunks::Model {
|
||||
created_at: Utc::now().into(),
|
||||
updated_at: Utc::now().into(),
|
||||
id: 0,
|
||||
source_slug: row.source_slug,
|
||||
source_title: row.source_title,
|
||||
source_path: None,
|
||||
source_type: "post".to_string(),
|
||||
chunk_index: row.chunk_index,
|
||||
content: row.content,
|
||||
content_preview: row.content_preview,
|
||||
embedding: None,
|
||||
word_count: row.word_count,
|
||||
},
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok((matches, indexed_chunks, last_indexed_at))
|
||||
}
|
||||
|
||||
pub async fn rebuild_index(ctx: &AppContext) -> Result<AiIndexSummary> {
|
||||
let settings = load_runtime_settings(ctx, false).await?;
|
||||
let posts = content::sync_markdown_posts(ctx).await?;
|
||||
let chunk_drafts = build_chunks(&posts, settings.chunk_size);
|
||||
let embeddings = if chunk_drafts.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
embed_texts_locally(
|
||||
chunk_drafts
|
||||
.iter()
|
||||
.map(|chunk| chunk.content.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
EmbeddingKind::Passage,
|
||||
)
|
||||
.await?
|
||||
};
|
||||
|
||||
ctx.db
|
||||
.execute(Statement::from_string(
|
||||
DbBackend::Postgres,
|
||||
"TRUNCATE TABLE ai_chunks RESTART IDENTITY".to_string(),
|
||||
))
|
||||
.await?;
|
||||
|
||||
for (draft, embedding) in chunk_drafts.iter().zip(embeddings.into_iter()) {
|
||||
let embedding_literal = vector_literal(&embedding)?;
|
||||
let statement = Statement::from_sql_and_values(
|
||||
DbBackend::Postgres,
|
||||
r#"
|
||||
INSERT INTO ai_chunks (
|
||||
source_slug,
|
||||
source_title,
|
||||
source_path,
|
||||
source_type,
|
||||
chunk_index,
|
||||
content,
|
||||
content_preview,
|
||||
embedding,
|
||||
word_count
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8::vector, $9
|
||||
)
|
||||
"#,
|
||||
vec![
|
||||
draft.source_slug.clone().into(),
|
||||
draft.source_title.clone().into(),
|
||||
draft.source_path.clone().into(),
|
||||
draft.source_type.clone().into(),
|
||||
draft.chunk_index.into(),
|
||||
draft.content.clone().into(),
|
||||
draft.content_preview.clone().into(),
|
||||
embedding_literal.into(),
|
||||
draft.word_count.into(),
|
||||
],
|
||||
);
|
||||
ctx.db.execute(statement).await?;
|
||||
}
|
||||
|
||||
let last_indexed_at = update_indexed_at(ctx, &settings.raw).await?;
|
||||
|
||||
Ok(AiIndexSummary {
|
||||
indexed_chunks: chunk_drafts.len(),
|
||||
last_indexed_at: Some(last_indexed_at),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn answer_question(ctx: &AppContext, question: &str) -> Result<AiAnswer> {
|
||||
let prepared = prepare_answer(ctx, question).await?;
|
||||
let answer = if let Some(immediate_answer) = prepared.immediate_answer.clone() {
|
||||
immediate_answer
|
||||
} else {
|
||||
let request = prepared.provider_request.as_ref().ok_or_else(|| {
|
||||
Error::BadRequest("AI provider request was not prepared".to_string())
|
||||
})?;
|
||||
request_chat_answer(request).await?
|
||||
};
|
||||
|
||||
Ok(AiAnswer {
|
||||
answer,
|
||||
sources: prepared.sources,
|
||||
indexed_chunks: prepared.indexed_chunks,
|
||||
last_indexed_at: prepared.last_indexed_at,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn provider_name(value: Option<&str>) -> String {
|
||||
trim_to_option(value.map(ToString::to_string))
|
||||
.unwrap_or_else(|| DEFAULT_AI_PROVIDER.to_string())
|
||||
}
|
||||
|
||||
pub fn default_api_base() -> &'static str {
|
||||
DEFAULT_AI_API_BASE
|
||||
}
|
||||
|
||||
pub fn default_api_key() -> &'static str {
|
||||
DEFAULT_AI_API_KEY
|
||||
}
|
||||
|
||||
pub fn default_chat_model() -> &'static str {
|
||||
DEFAULT_CHAT_MODEL
|
||||
}
|
||||
|
||||
pub fn local_embedding_label() -> &'static str {
|
||||
LOCAL_EMBEDDING_MODEL_LABEL
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, QueryOrder, Set,
|
||||
ActiveModelTrait, ColumnTrait, Condition, EntityTrait, IntoActiveModel, QueryFilter,
|
||||
QueryOrder, Set,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::models::_entities::{categories, posts, tags};
|
||||
use crate::models::_entities::{categories, comments, posts, tags};
|
||||
|
||||
pub const MARKDOWN_POSTS_DIR: &str = "content/posts";
|
||||
const FIXTURE_POSTS_FILE: &str = "src/fixtures/posts.yaml";
|
||||
@@ -120,6 +121,19 @@ fn slugify(value: &str) -> String {
|
||||
slug.trim_matches('-').to_string()
|
||||
}
|
||||
|
||||
fn normalized_match_key(value: &str) -> String {
|
||||
value.trim().to_lowercase()
|
||||
}
|
||||
|
||||
fn same_text(left: &str, right: &str) -> bool {
|
||||
normalized_match_key(left) == normalized_match_key(right)
|
||||
}
|
||||
|
||||
fn text_matches_any(value: &str, keys: &[String]) -> bool {
|
||||
let current = normalized_match_key(value);
|
||||
!current.is_empty() && keys.iter().any(|key| current == *key)
|
||||
}
|
||||
|
||||
fn excerpt_from_content(content: &str) -> Option<String> {
|
||||
let mut in_code_block = false;
|
||||
|
||||
@@ -135,7 +149,11 @@ fn excerpt_from_content(content: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
let excerpt = trimmed.chars().take(180).collect::<String>();
|
||||
return if excerpt.is_empty() { None } else { Some(excerpt) };
|
||||
return if excerpt.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(excerpt)
|
||||
};
|
||||
}
|
||||
|
||||
None
|
||||
@@ -188,7 +206,8 @@ fn parse_markdown_source(file_stem: &str, raw: &str, file_path: &str) -> Result<
|
||||
let title = trim_to_option(frontmatter.title.clone())
|
||||
.or_else(|| title_from_content(&content))
|
||||
.unwrap_or_else(|| slug.clone());
|
||||
let description = trim_to_option(frontmatter.description.clone()).or_else(|| excerpt_from_content(&content));
|
||||
let description =
|
||||
trim_to_option(frontmatter.description.clone()).or_else(|| excerpt_from_content(&content));
|
||||
let category = trim_to_option(frontmatter.category.clone());
|
||||
let tags = frontmatter
|
||||
.tags
|
||||
@@ -205,7 +224,8 @@ fn parse_markdown_source(file_stem: &str, raw: &str, file_path: &str) -> Result<
|
||||
content: content.trim_start_matches('\n').to_string(),
|
||||
category,
|
||||
tags,
|
||||
post_type: trim_to_option(frontmatter.post_type.clone()).unwrap_or_else(|| "article".to_string()),
|
||||
post_type: trim_to_option(frontmatter.post_type.clone())
|
||||
.unwrap_or_else(|| "article".to_string()),
|
||||
image: trim_to_option(frontmatter.image.clone()),
|
||||
pinned: frontmatter.pinned.unwrap_or(false),
|
||||
published: frontmatter.published.unwrap_or(true),
|
||||
@@ -216,7 +236,12 @@ fn parse_markdown_source(file_stem: &str, raw: &str, file_path: &str) -> Result<
|
||||
fn build_markdown_document(post: &MarkdownPost) -> String {
|
||||
let mut lines = vec![
|
||||
"---".to_string(),
|
||||
format!("title: {}", serde_yaml::to_string(&post.title).unwrap_or_else(|_| format!("{:?}", post.title)).trim()),
|
||||
format!(
|
||||
"title: {}",
|
||||
serde_yaml::to_string(&post.title)
|
||||
.unwrap_or_else(|_| format!("{:?}", post.title))
|
||||
.trim()
|
||||
),
|
||||
format!("slug: {}", post.slug),
|
||||
];
|
||||
|
||||
@@ -284,10 +309,16 @@ fn ensure_markdown_posts_bootstrapped() -> Result<()> {
|
||||
image: None,
|
||||
pinned: fixture.pinned.unwrap_or(false),
|
||||
published: fixture.published.unwrap_or(true),
|
||||
file_path: markdown_post_path(&fixture.slug).to_string_lossy().to_string(),
|
||||
file_path: markdown_post_path(&fixture.slug)
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
};
|
||||
|
||||
fs::write(markdown_post_path(&fixture.slug), build_markdown_document(&post)).map_err(io_error)?;
|
||||
fs::write(
|
||||
markdown_post_path(&fixture.slug),
|
||||
build_markdown_document(&post),
|
||||
)
|
||||
.map_err(io_error)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -312,14 +343,19 @@ async fn sync_tags_from_posts(ctx: &AppContext, posts: &[MarkdownPost]) -> Resul
|
||||
for post in posts {
|
||||
for tag_name in &post.tags {
|
||||
let slug = slugify(tag_name);
|
||||
let trimmed = tag_name.trim();
|
||||
let existing = tags::Entity::find()
|
||||
.filter(tags::Column::Slug.eq(&slug))
|
||||
.filter(
|
||||
Condition::any()
|
||||
.add(tags::Column::Slug.eq(&slug))
|
||||
.add(tags::Column::Name.eq(trimmed)),
|
||||
)
|
||||
.one(&ctx.db)
|
||||
.await?;
|
||||
|
||||
if existing.is_none() {
|
||||
let item = tags::ActiveModel {
|
||||
name: Set(Some(tag_name.clone())),
|
||||
name: Set(Some(trimmed.to_string())),
|
||||
slug: Set(slug),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -339,12 +375,21 @@ async fn ensure_category(ctx: &AppContext, raw_name: &str) -> Result<Option<Stri
|
||||
|
||||
let slug = slugify(name);
|
||||
let existing = categories::Entity::find()
|
||||
.filter(categories::Column::Slug.eq(&slug))
|
||||
.filter(
|
||||
Condition::any()
|
||||
.add(categories::Column::Slug.eq(&slug))
|
||||
.add(categories::Column::Name.eq(name)),
|
||||
)
|
||||
.one(&ctx.db)
|
||||
.await?;
|
||||
|
||||
if let Some(category) = existing {
|
||||
if let Some(existing_name) = category.name.as_deref().map(str::trim).filter(|value| !value.is_empty()) {
|
||||
if let Some(existing_name) = category
|
||||
.name
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
return Ok(Some(existing_name.to_string()));
|
||||
}
|
||||
|
||||
@@ -381,12 +426,21 @@ async fn canonicalize_tags(ctx: &AppContext, raw_tags: &[String]) -> Result<Vec<
|
||||
}
|
||||
|
||||
let existing = tags::Entity::find()
|
||||
.filter(tags::Column::Slug.eq(&slug))
|
||||
.filter(
|
||||
Condition::any()
|
||||
.add(tags::Column::Slug.eq(&slug))
|
||||
.add(tags::Column::Name.eq(trimmed)),
|
||||
)
|
||||
.one(&ctx.db)
|
||||
.await?;
|
||||
|
||||
let canonical_name = if let Some(tag) = existing {
|
||||
if let Some(existing_name) = tag.name.as_deref().map(str::trim).filter(|value| !value.is_empty()) {
|
||||
if let Some(existing_name) = tag
|
||||
.name
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
existing_name.to_string()
|
||||
} else {
|
||||
let mut tag_model = tag.into_active_model();
|
||||
@@ -415,6 +469,132 @@ async fn canonicalize_tags(ctx: &AppContext, raw_tags: &[String]) -> Result<Vec<
|
||||
Ok(canonical_tags)
|
||||
}
|
||||
|
||||
fn write_markdown_post_to_disk(post: &MarkdownPost) -> Result<()> {
|
||||
fs::write(markdown_post_path(&post.slug), build_markdown_document(post)).map_err(io_error)
|
||||
}
|
||||
|
||||
pub fn rewrite_category_references(
|
||||
current_name: Option<&str>,
|
||||
current_slug: &str,
|
||||
next_name: Option<&str>,
|
||||
) -> Result<usize> {
|
||||
ensure_markdown_posts_bootstrapped()?;
|
||||
|
||||
let mut match_keys = Vec::new();
|
||||
if let Some(name) = current_name {
|
||||
let normalized = normalized_match_key(name);
|
||||
if !normalized.is_empty() {
|
||||
match_keys.push(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
let normalized_slug = normalized_match_key(current_slug);
|
||||
if !normalized_slug.is_empty() {
|
||||
match_keys.push(normalized_slug);
|
||||
}
|
||||
|
||||
if match_keys.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let next_category = next_name
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string);
|
||||
let mut changed = 0_usize;
|
||||
let mut posts = load_markdown_posts_from_disk()?;
|
||||
|
||||
for post in &mut posts {
|
||||
let Some(category) = post.category.as_deref() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if !text_matches_any(category, &match_keys) {
|
||||
continue;
|
||||
}
|
||||
|
||||
match &next_category {
|
||||
Some(updated_name) if same_text(category, updated_name) => {}
|
||||
Some(updated_name) => {
|
||||
post.category = Some(updated_name.clone());
|
||||
write_markdown_post_to_disk(post)?;
|
||||
changed += 1;
|
||||
}
|
||||
None => {
|
||||
post.category = None;
|
||||
write_markdown_post_to_disk(post)?;
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
pub fn rewrite_tag_references(
|
||||
current_name: Option<&str>,
|
||||
current_slug: &str,
|
||||
next_name: Option<&str>,
|
||||
) -> Result<usize> {
|
||||
ensure_markdown_posts_bootstrapped()?;
|
||||
|
||||
let mut match_keys = Vec::new();
|
||||
if let Some(name) = current_name {
|
||||
let normalized = normalized_match_key(name);
|
||||
if !normalized.is_empty() {
|
||||
match_keys.push(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
let normalized_slug = normalized_match_key(current_slug);
|
||||
if !normalized_slug.is_empty() {
|
||||
match_keys.push(normalized_slug);
|
||||
}
|
||||
|
||||
if match_keys.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
|
||||
let next_tag = next_name
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string);
|
||||
let mut changed = 0_usize;
|
||||
let mut posts = load_markdown_posts_from_disk()?;
|
||||
|
||||
for post in &mut posts {
|
||||
let mut updated_tags = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut post_changed = false;
|
||||
|
||||
for tag in &post.tags {
|
||||
if text_matches_any(tag, &match_keys) {
|
||||
post_changed = true;
|
||||
if let Some(next_tag_name) = &next_tag {
|
||||
let normalized = normalized_match_key(next_tag_name);
|
||||
if seen.insert(normalized) {
|
||||
updated_tags.push(next_tag_name.clone());
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let normalized = normalized_match_key(tag);
|
||||
if seen.insert(normalized) {
|
||||
updated_tags.push(tag.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if post_changed {
|
||||
post.tags = updated_tags;
|
||||
write_markdown_post_to_disk(post)?;
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
async fn dedupe_tags(ctx: &AppContext) -> Result<()> {
|
||||
let existing_tags = tags::Entity::find()
|
||||
.order_by_asc(tags::Column::Id)
|
||||
@@ -425,10 +605,7 @@ async fn dedupe_tags(ctx: &AppContext) -> Result<()> {
|
||||
|
||||
for tag in existing_tags {
|
||||
let key = if tag.slug.trim().is_empty() {
|
||||
tag.name
|
||||
.as_deref()
|
||||
.map(slugify)
|
||||
.unwrap_or_default()
|
||||
tag.name.as_deref().map(slugify).unwrap_or_default()
|
||||
} else {
|
||||
slugify(&tag.slug)
|
||||
};
|
||||
@@ -453,11 +630,7 @@ async fn dedupe_categories(ctx: &AppContext) -> Result<()> {
|
||||
|
||||
for category in existing_categories {
|
||||
let key = if category.slug.trim().is_empty() {
|
||||
category
|
||||
.name
|
||||
.as_deref()
|
||||
.map(slugify)
|
||||
.unwrap_or_default()
|
||||
category.name.as_deref().map(slugify).unwrap_or_default()
|
||||
} else {
|
||||
slugify(&category.slug)
|
||||
};
|
||||
@@ -474,6 +647,28 @@ async fn dedupe_categories(ctx: &AppContext) -> Result<()> {
|
||||
|
||||
pub async fn sync_markdown_posts(ctx: &AppContext) -> Result<Vec<MarkdownPost>> {
|
||||
let markdown_posts = load_markdown_posts_from_disk()?;
|
||||
let markdown_slugs = markdown_posts
|
||||
.iter()
|
||||
.map(|post| post.slug.clone())
|
||||
.collect::<std::collections::HashSet<_>>();
|
||||
let existing_posts = posts::Entity::find().all(&ctx.db).await?;
|
||||
|
||||
for stale_post in existing_posts
|
||||
.into_iter()
|
||||
.filter(|post| !markdown_slugs.contains(&post.slug))
|
||||
{
|
||||
let stale_slug = stale_post.slug.clone();
|
||||
let related_comments = comments::Entity::find()
|
||||
.filter(comments::Column::PostSlug.eq(&stale_slug))
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
|
||||
for comment in related_comments {
|
||||
let _ = comment.delete(&ctx.db).await;
|
||||
}
|
||||
|
||||
let _ = stale_post.delete(&ctx.db).await;
|
||||
}
|
||||
|
||||
for post in &markdown_posts {
|
||||
let canonical_category = match post.category.as_deref() {
|
||||
@@ -545,6 +740,18 @@ pub async fn write_markdown_document(
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
pub async fn delete_markdown_post(ctx: &AppContext, slug: &str) -> Result<()> {
|
||||
ensure_markdown_posts_bootstrapped()?;
|
||||
let path = markdown_post_path(slug);
|
||||
if !path.exists() {
|
||||
return Err(Error::NotFound);
|
||||
}
|
||||
|
||||
fs::remove_file(&path).map_err(io_error)?;
|
||||
sync_markdown_posts(ctx).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create_markdown_post(
|
||||
ctx: &AppContext,
|
||||
draft: MarkdownPostDraft,
|
||||
@@ -594,9 +801,16 @@ pub async fn create_markdown_post(
|
||||
file_path: markdown_post_path(&slug).to_string_lossy().to_string(),
|
||||
};
|
||||
|
||||
fs::write(markdown_post_path(&slug), build_markdown_document(&post)).map_err(io_error)?;
|
||||
let path = markdown_post_path(&slug);
|
||||
if path.exists() {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"markdown post already exists for slug: {slug}"
|
||||
)));
|
||||
}
|
||||
|
||||
fs::write(&path, build_markdown_document(&post)).map_err(io_error)?;
|
||||
sync_markdown_posts(ctx).await?;
|
||||
parse_markdown_post(&markdown_post_path(&slug))
|
||||
parse_markdown_post(&path)
|
||||
}
|
||||
|
||||
pub async fn import_markdown_documents(
|
||||
@@ -635,7 +849,8 @@ pub async fn import_markdown_documents(
|
||||
continue;
|
||||
}
|
||||
|
||||
fs::write(markdown_post_path(&slug), normalize_newlines(&file.content)).map_err(io_error)?;
|
||||
fs::write(markdown_post_path(&slug), normalize_newlines(&file.content))
|
||||
.map_err(io_error)?;
|
||||
imported_slugs.push(slug);
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
pub mod ai;
|
||||
pub mod content;
|
||||
|
||||
Reference in New Issue
Block a user