chore: checkpoint ai search comments and i18n foundation

This commit is contained in:
2026-03-28 17:17:31 +08:00
parent d18a709987
commit ec96d91548
71 changed files with 9494 additions and 423 deletions

View File

@@ -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(())
}
}

View File

@@ -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))

View 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))
}

View File

@@ -136,16 +136,32 @@ pub async fn update(
let name = normalized_name(&params)?;
let slug = normalized_slug(&params, &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()
}

View File

@@ -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))

View File

@@ -1,4 +1,5 @@
pub mod admin;
pub mod ai;
pub mod auth;
pub mod category;
pub mod comment;

View File

@@ -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))

View File

@@ -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?,
}

View File

@@ -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 {

View File

@@ -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()
}

View File

@@ -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

View File

@@ -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()
};

View 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 {}

View File

@@ -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>,
}

View File

@@ -1,5 +1,6 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.10
pub mod ai_chunks;
pub mod prelude;
pub mod categories;

View File

@@ -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;

View File

@@ -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)]

View File

@@ -0,0 +1,3 @@
pub use super::_entities::ai_chunks::{ActiveModel, Entity, Model};
pub type AiChunks = Entity;

View File

@@ -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
View 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
}

View File

@@ -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);
}

View File

@@ -1 +1,2 @@
pub mod ai;
pub mod content;