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

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