chore: checkpoint ai search comments and i18n foundation
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user