feat: Refactor service management scripts to use a unified dev script
- Added package.json to manage development scripts. - Updated restart-services.ps1 to call the new dev script for starting services. - Refactored start-admin.ps1, start-backend.ps1, start-frontend.ps1, and start-mcp.ps1 to utilize the dev script for starting respective services. - Enhanced stop-services.ps1 to improve process termination logic by matching command patterns.
This commit is contained in:
@@ -277,6 +277,27 @@ impl Hooks for App {
|
||||
})
|
||||
.filter(|items| !items.is_empty())
|
||||
.map(|items| serde_json::json!(items));
|
||||
let music_playlist = settings["music_playlist"]
|
||||
.as_array()
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
let title = item["title"].as_str()?.trim();
|
||||
let url = item["url"].as_str()?.trim();
|
||||
if title.is_empty() || url.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(serde_json::json!({
|
||||
"title": title,
|
||||
"url": url,
|
||||
}))
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.filter(|items| !items.is_empty())
|
||||
.map(serde_json::Value::Array);
|
||||
|
||||
let item = site_settings::ActiveModel {
|
||||
id: Set(settings["id"].as_i64().unwrap_or(1) as i32),
|
||||
@@ -317,7 +338,11 @@ impl Hooks for App {
|
||||
.map(ToString::to_string)),
|
||||
location: Set(settings["location"].as_str().map(ToString::to_string)),
|
||||
tech_stack: Set(tech_stack),
|
||||
music_playlist: Set(music_playlist),
|
||||
ai_enabled: Set(settings["ai_enabled"].as_bool()),
|
||||
paragraph_comments_enabled: Set(settings["paragraph_comments_enabled"]
|
||||
.as_bool()
|
||||
.or(Some(true))),
|
||||
ai_provider: Set(settings["ai_provider"].as_str().map(ToString::to_string)),
|
||||
ai_api_base: Set(settings["ai_api_base"].as_str().map(ToString::to_string)),
|
||||
ai_api_key: Set(settings["ai_api_key"].as_str().map(ToString::to_string)),
|
||||
@@ -353,6 +378,11 @@ impl Hooks for App {
|
||||
let status = review["status"].as_str().unwrap_or("completed").to_string();
|
||||
let description = review["description"].as_str().unwrap_or("").to_string();
|
||||
let cover = review["cover"].as_str().unwrap_or("📝").to_string();
|
||||
let link_url = review["link_url"]
|
||||
.as_str()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToString::to_string);
|
||||
let tags_vec = review["tags"]
|
||||
.as_array()
|
||||
.map(|arr| {
|
||||
@@ -376,6 +406,7 @@ impl Hooks for App {
|
||||
status: Set(Some(status)),
|
||||
description: Set(Some(description)),
|
||||
cover: Set(Some(cover)),
|
||||
link_url: Set(link_url),
|
||||
tags: Set(Some(serde_json::to_string(&tags_vec).unwrap_or_default())),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -167,6 +167,7 @@ struct ReviewRow {
|
||||
description: String,
|
||||
tags_input: String,
|
||||
cover: String,
|
||||
link_url: String,
|
||||
api_url: String,
|
||||
}
|
||||
|
||||
@@ -205,6 +206,7 @@ pub struct ReviewForm {
|
||||
description: String,
|
||||
tags: String,
|
||||
cover: String,
|
||||
link_url: String,
|
||||
}
|
||||
|
||||
fn url_encode(value: &str) -> String {
|
||||
@@ -704,6 +706,7 @@ pub async fn posts_create(
|
||||
tags: parse_tag_input(&form.tags),
|
||||
post_type: normalize_admin_text(&form.post_type),
|
||||
image: Some(normalize_admin_text(&form.image)),
|
||||
images: Vec::new(),
|
||||
pinned: form.pinned.is_some(),
|
||||
published: form.published.is_some(),
|
||||
},
|
||||
@@ -818,8 +821,14 @@ pub async fn comments_admin(
|
||||
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 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))
|
||||
@@ -827,12 +836,7 @@ pub async fn comments_admin(
|
||||
|
||||
let author_by_id = items
|
||||
.iter()
|
||||
.map(|comment| {
|
||||
(
|
||||
comment.id,
|
||||
non_empty(comment.author.as_deref(), "匿名"),
|
||||
)
|
||||
})
|
||||
.map(|comment| (comment.id, non_empty(comment.author.as_deref(), "匿名")))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
|
||||
let post_options = items
|
||||
@@ -1263,6 +1267,7 @@ pub async fn reviews_admin(
|
||||
description: non_empty(review.description.as_deref(), ""),
|
||||
tags_input: review_tags_input(review.tags.as_deref()),
|
||||
cover: non_empty(review.cover.as_deref(), "🎮"),
|
||||
link_url: non_empty(review.link_url.as_deref(), ""),
|
||||
api_url: format!("/api/reviews/{}", review.id),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
@@ -1290,6 +1295,7 @@ pub async fn reviews_admin(
|
||||
"description": "",
|
||||
"tags": "",
|
||||
"cover": "🎮",
|
||||
"link_url": "",
|
||||
}),
|
||||
);
|
||||
context.insert("rows".into(), json!(rows));
|
||||
@@ -1314,6 +1320,10 @@ pub async fn reviews_create(
|
||||
serde_json::to_string(&parse_review_tags(&form.tags)).unwrap_or_default(),
|
||||
)),
|
||||
cover: Set(Some(normalize_admin_text(&form.cover))),
|
||||
link_url: Set({
|
||||
let value = normalize_admin_text(&form.link_url);
|
||||
(!value.is_empty()).then_some(value)
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&ctx.db)
|
||||
@@ -1345,6 +1355,10 @@ pub async fn reviews_update(
|
||||
serde_json::to_string(&parse_review_tags(&form.tags)).unwrap_or_default(),
|
||||
));
|
||||
model.cover = Set(Some(normalize_admin_text(&form.cover)));
|
||||
model.link_url = Set({
|
||||
let value = normalize_admin_text(&form.link_url);
|
||||
(!value.is_empty()).then_some(value)
|
||||
});
|
||||
let _ = model.update(&ctx.db).await?;
|
||||
|
||||
Ok(format::redirect("/admin/reviews"))
|
||||
|
||||
@@ -7,7 +7,10 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
controllers::{
|
||||
admin::{admin_username, check_auth, is_admin_logged_in, set_admin_logged_in, validate_admin_credentials},
|
||||
admin::{
|
||||
admin_username, check_auth, is_admin_logged_in, set_admin_logged_in,
|
||||
validate_admin_credentials,
|
||||
},
|
||||
site_settings::{self, SiteSettingsPayload},
|
||||
},
|
||||
models::_entities::{ai_chunks, comments, friend_links, posts, reviews},
|
||||
@@ -120,11 +123,15 @@ pub struct AdminSiteSettingsResponse {
|
||||
pub social_email: Option<String>,
|
||||
pub location: Option<String>,
|
||||
pub tech_stack: Vec<String>,
|
||||
pub music_playlist: Vec<site_settings::MusicTrackPayload>,
|
||||
pub ai_enabled: bool,
|
||||
pub paragraph_comments_enabled: bool,
|
||||
pub ai_provider: Option<String>,
|
||||
pub ai_api_base: Option<String>,
|
||||
pub ai_api_key: Option<String>,
|
||||
pub ai_chat_model: Option<String>,
|
||||
pub ai_providers: Vec<site_settings::AiProviderConfig>,
|
||||
pub ai_active_provider_id: Option<String>,
|
||||
pub ai_embedding_model: Option<String>,
|
||||
pub ai_system_prompt: Option<String>,
|
||||
pub ai_top_k: Option<i32>,
|
||||
@@ -140,6 +147,29 @@ pub struct AdminAiReindexResponse {
|
||||
pub last_indexed_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct AdminAiProviderTestRequest {
|
||||
pub provider: site_settings::AiProviderConfig,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct AdminAiProviderTestResponse {
|
||||
pub provider: String,
|
||||
pub endpoint: String,
|
||||
pub chat_model: String,
|
||||
pub reply_preview: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct AdminPostMetadataRequest {
|
||||
pub markdown: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct AdminPostPolishRequest {
|
||||
pub markdown: String,
|
||||
}
|
||||
|
||||
fn format_timestamp(
|
||||
value: Option<sea_orm::prelude::DateTimeWithTimeZone>,
|
||||
pattern: &str,
|
||||
@@ -166,10 +196,27 @@ fn tech_stack_values(value: &Option<serde_json::Value>) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn music_playlist_values(
|
||||
value: &Option<serde_json::Value>,
|
||||
) -> Vec<site_settings::MusicTrackPayload> {
|
||||
value
|
||||
.as_ref()
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|item| serde_json::from_value::<site_settings::MusicTrackPayload>(item).ok())
|
||||
.filter(|item| !item.title.trim().is_empty() && !item.url.trim().is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn build_settings_response(
|
||||
item: crate::models::_entities::site_settings::Model,
|
||||
ai_chunks_count: u64,
|
||||
) -> AdminSiteSettingsResponse {
|
||||
let ai_providers = site_settings::ai_provider_configs(&item);
|
||||
let ai_active_provider_id = site_settings::active_ai_provider_id(&item);
|
||||
|
||||
AdminSiteSettingsResponse {
|
||||
id: item.id,
|
||||
site_name: item.site_name,
|
||||
@@ -188,11 +235,15 @@ fn build_settings_response(
|
||||
social_email: item.social_email,
|
||||
location: item.location,
|
||||
tech_stack: tech_stack_values(&item.tech_stack),
|
||||
music_playlist: music_playlist_values(&item.music_playlist),
|
||||
ai_enabled: item.ai_enabled.unwrap_or(false),
|
||||
paragraph_comments_enabled: item.paragraph_comments_enabled.unwrap_or(true),
|
||||
ai_provider: item.ai_provider,
|
||||
ai_api_base: item.ai_api_base,
|
||||
ai_api_key: item.ai_api_key,
|
||||
ai_chat_model: item.ai_chat_model,
|
||||
ai_providers,
|
||||
ai_active_provider_id,
|
||||
ai_embedding_model: item.ai_embedding_model,
|
||||
ai_system_prompt: item.ai_system_prompt,
|
||||
ai_top_k: item.ai_top_k,
|
||||
@@ -375,8 +426,9 @@ pub async fn update_site_settings(
|
||||
check_auth()?;
|
||||
|
||||
let current = site_settings::load_current(&ctx).await?;
|
||||
let mut item = current.into_active_model();
|
||||
let mut item = current;
|
||||
params.apply(&mut item);
|
||||
let item = item.into_active_model();
|
||||
let updated = item.update(&ctx.db).await?;
|
||||
let ai_chunks_count = ai_chunks::Entity::find().count(&ctx.db).await?;
|
||||
|
||||
@@ -390,10 +442,51 @@ pub async fn reindex_ai(State(ctx): State<AppContext>) -> Result<Response> {
|
||||
|
||||
format::json(AdminAiReindexResponse {
|
||||
indexed_chunks: summary.indexed_chunks,
|
||||
last_indexed_at: format_timestamp(summary.last_indexed_at.map(Into::into), "%Y-%m-%d %H:%M:%S UTC"),
|
||||
last_indexed_at: format_timestamp(
|
||||
summary.last_indexed_at.map(Into::into),
|
||||
"%Y-%m-%d %H:%M:%S UTC",
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn test_ai_provider(Json(payload): Json<AdminAiProviderTestRequest>) -> Result<Response> {
|
||||
check_auth()?;
|
||||
|
||||
let result = ai::test_provider_connectivity(
|
||||
&payload.provider.provider,
|
||||
payload.provider.api_base.as_deref().unwrap_or_default(),
|
||||
payload.provider.api_key.as_deref().unwrap_or_default(),
|
||||
payload.provider.chat_model.as_deref().unwrap_or_default(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
format::json(AdminAiProviderTestResponse {
|
||||
provider: result.provider,
|
||||
endpoint: result.endpoint,
|
||||
chat_model: result.chat_model,
|
||||
reply_preview: result.reply_preview,
|
||||
})
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn generate_post_metadata(
|
||||
State(ctx): State<AppContext>,
|
||||
Json(payload): Json<AdminPostMetadataRequest>,
|
||||
) -> Result<Response> {
|
||||
check_auth()?;
|
||||
format::json(ai::generate_post_metadata(&ctx, &payload.markdown).await?)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn polish_post_markdown(
|
||||
State(ctx): State<AppContext>,
|
||||
Json(payload): Json<AdminPostPolishRequest>,
|
||||
) -> Result<Response> {
|
||||
check_auth()?;
|
||||
format::json(ai::polish_post_markdown(&ctx, &payload.markdown).await?)
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.prefix("/api/admin")
|
||||
@@ -405,4 +498,7 @@ pub fn routes() -> Routes {
|
||||
.add("/site-settings", patch(update_site_settings))
|
||||
.add("/site-settings", put(update_site_settings))
|
||||
.add("/ai/reindex", post(reindex_ai))
|
||||
.add("/ai/test-provider", post(test_ai_provider))
|
||||
.add("/ai/post-metadata", post(generate_post_metadata))
|
||||
.add("/ai/polish-post", post(polish_post_markdown))
|
||||
}
|
||||
|
||||
@@ -56,9 +56,8 @@ fn format_timestamp(value: Option<DateTime<Utc>>) -> Option<String> {
|
||||
}
|
||||
|
||||
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()
|
||||
});
|
||||
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"))
|
||||
}
|
||||
@@ -127,7 +126,8 @@ fn extract_stream_delta(value: &Value) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
value.get("choices")
|
||||
value
|
||||
.get("choices")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|choices| choices.first())
|
||||
.and_then(|choice| choice.get("text"))
|
||||
|
||||
@@ -145,7 +145,11 @@ pub async fn update(
|
||||
.filter(|value| !value.is_empty())
|
||||
!= Some(name.as_str())
|
||||
{
|
||||
content::rewrite_category_references(previous_name.as_deref(), &previous_slug, Some(&name))?;
|
||||
content::rewrite_category_references(
|
||||
previous_name.as_deref(),
|
||||
&previous_slug,
|
||||
Some(&name),
|
||||
)?;
|
||||
}
|
||||
|
||||
let mut item = item.into_active_model();
|
||||
|
||||
@@ -243,7 +243,10 @@ pub async fn paragraph_summary(
|
||||
|
||||
let summary = counts
|
||||
.into_iter()
|
||||
.map(|(paragraph_key, count)| ParagraphCommentSummary { paragraph_key, count })
|
||||
.map(|(paragraph_key, count)| ParagraphCommentSummary {
|
||||
paragraph_key,
|
||||
count,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
format::json(summary)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#![allow(clippy::missing_errors_doc)]
|
||||
#![allow(clippy::unnecessary_struct_initialization)]
|
||||
#![allow(clippy::unused_async)]
|
||||
use axum::extract::Multipart;
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::QueryOrder;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -18,6 +19,7 @@ pub struct Params {
|
||||
pub tags: Option<serde_json::Value>,
|
||||
pub post_type: Option<String>,
|
||||
pub image: Option<String>,
|
||||
pub images: Option<serde_json::Value>,
|
||||
pub pinned: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -31,6 +33,7 @@ impl Params {
|
||||
item.tags = Set(self.tags.clone());
|
||||
item.post_type = Set(self.post_type.clone());
|
||||
item.image = Set(self.image.clone());
|
||||
item.images = Set(self.images.clone());
|
||||
item.pinned = Set(self.pinned);
|
||||
}
|
||||
}
|
||||
@@ -61,6 +64,7 @@ pub struct MarkdownCreateParams {
|
||||
pub tags: Option<Vec<String>>,
|
||||
pub post_type: Option<String>,
|
||||
pub image: Option<String>,
|
||||
pub images: Option<Vec<String>>,
|
||||
pub pinned: Option<bool>,
|
||||
pub published: Option<bool>,
|
||||
}
|
||||
@@ -78,6 +82,12 @@ pub struct MarkdownDeleteResponse {
|
||||
pub deleted: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct MarkdownImportResponse {
|
||||
pub count: usize,
|
||||
pub slugs: Vec<String>,
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -293,6 +303,7 @@ pub async fn create_markdown(
|
||||
tags: params.tags.unwrap_or_default(),
|
||||
post_type: params.post_type.unwrap_or_else(|| "article".to_string()),
|
||||
image: params.image,
|
||||
images: params.images.unwrap_or_default(),
|
||||
pinned: params.pinned.unwrap_or(false),
|
||||
published: params.published.unwrap_or(true),
|
||||
},
|
||||
@@ -307,6 +318,40 @@ pub async fn create_markdown(
|
||||
})
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn import_markdown(
|
||||
State(ctx): State<AppContext>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Response> {
|
||||
let mut files = Vec::new();
|
||||
|
||||
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)
|
||||
.unwrap_or_else(|| "imported.md".to_string());
|
||||
let bytes = field
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|error| Error::BadRequest(error.to_string()))?;
|
||||
let content = String::from_utf8(bytes.to_vec())
|
||||
.map_err(|_| Error::BadRequest("markdown file must be utf-8".to_string()))?;
|
||||
|
||||
files.push(content::MarkdownImportFile { file_name, content });
|
||||
}
|
||||
|
||||
let imported = content::import_markdown_documents(&ctx, files).await?;
|
||||
|
||||
format::json(MarkdownImportResponse {
|
||||
count: imported.len(),
|
||||
slugs: imported.into_iter().map(|item| item.slug).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
pub async fn delete_markdown_by_slug(
|
||||
Path(slug): Path<String>,
|
||||
@@ -325,6 +370,7 @@ pub fn routes() -> Routes {
|
||||
.add("/", get(list))
|
||||
.add("/", post(add))
|
||||
.add("markdown", post(create_markdown))
|
||||
.add("markdown/import", post(import_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))
|
||||
|
||||
@@ -15,6 +15,7 @@ pub struct CreateReviewRequest {
|
||||
pub description: String,
|
||||
pub tags: Vec<String>,
|
||||
pub cover: String,
|
||||
pub link_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
@@ -27,6 +28,7 @@ pub struct UpdateReviewRequest {
|
||||
pub description: Option<String>,
|
||||
pub tags: Option<Vec<String>>,
|
||||
pub cover: Option<String>,
|
||||
pub link_url: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn list(State(ctx): State<AppContext>) -> Result<impl IntoResponse> {
|
||||
@@ -63,6 +65,10 @@ pub async fn create(
|
||||
description: Set(Some(req.description)),
|
||||
tags: Set(Some(serde_json::to_string(&req.tags).unwrap_or_default())),
|
||||
cover: Set(Some(req.cover)),
|
||||
link_url: Set(req.link_url.and_then(|value| {
|
||||
let trimmed = value.trim().to_string();
|
||||
(!trimmed.is_empty()).then_some(trimmed)
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -105,6 +111,10 @@ pub async fn update(
|
||||
if let Some(cover) = req.cover {
|
||||
review.cover = Set(Some(cover));
|
||||
}
|
||||
if let Some(link_url) = req.link_url {
|
||||
let trimmed = link_url.trim().to_string();
|
||||
review.link_url = Set((!trimmed.is_empty()).then_some(trimmed));
|
||||
}
|
||||
|
||||
let review = review.update(&ctx.db).await?;
|
||||
format::json(review)
|
||||
|
||||
@@ -178,7 +178,8 @@ pub async fn search(
|
||||
.all(&ctx.db)
|
||||
.await
|
||||
{
|
||||
Ok(rows) => rows,
|
||||
Ok(rows) if !rows.is_empty() => rows,
|
||||
Ok(_) => fallback_search(&ctx, &q, limit).await?,
|
||||
Err(_) => fallback_search(&ctx, &q, limit).await?,
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{ActiveModelTrait, EntityTrait, IntoActiveModel, QueryOrder, Set};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
controllers::admin::check_auth,
|
||||
@@ -12,6 +14,38 @@ use crate::{
|
||||
services::ai,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct MusicTrackPayload {
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub artist: Option<String>,
|
||||
#[serde(default)]
|
||||
pub album: Option<String>,
|
||||
pub url: String,
|
||||
#[serde(default, alias = "coverImageUrl")]
|
||||
pub cover_image_url: Option<String>,
|
||||
#[serde(default, alias = "accentColor")]
|
||||
pub accent_color: Option<String>,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct AiProviderConfig {
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
#[serde(default, alias = "label")]
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub provider: String,
|
||||
#[serde(default, alias = "apiBase")]
|
||||
pub api_base: Option<String>,
|
||||
#[serde(default, alias = "apiKey")]
|
||||
pub api_key: Option<String>,
|
||||
#[serde(default, alias = "chatModel")]
|
||||
pub chat_model: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct SiteSettingsPayload {
|
||||
#[serde(default, alias = "siteName")]
|
||||
@@ -46,8 +80,12 @@ pub struct SiteSettingsPayload {
|
||||
pub location: Option<String>,
|
||||
#[serde(default, alias = "techStack")]
|
||||
pub tech_stack: Option<Vec<String>>,
|
||||
#[serde(default, alias = "musicPlaylist")]
|
||||
pub music_playlist: Option<Vec<MusicTrackPayload>>,
|
||||
#[serde(default, alias = "aiEnabled")]
|
||||
pub ai_enabled: Option<bool>,
|
||||
#[serde(default, alias = "paragraphCommentsEnabled")]
|
||||
pub paragraph_comments_enabled: Option<bool>,
|
||||
#[serde(default, alias = "aiProvider")]
|
||||
pub ai_provider: Option<String>,
|
||||
#[serde(default, alias = "aiApiBase")]
|
||||
@@ -56,6 +94,10 @@ pub struct SiteSettingsPayload {
|
||||
pub ai_api_key: Option<String>,
|
||||
#[serde(default, alias = "aiChatModel")]
|
||||
pub ai_chat_model: Option<String>,
|
||||
#[serde(default, alias = "aiProviders")]
|
||||
pub ai_providers: Option<Vec<AiProviderConfig>>,
|
||||
#[serde(default, alias = "aiActiveProviderId")]
|
||||
pub ai_active_provider_id: Option<String>,
|
||||
#[serde(default, alias = "aiEmbeddingModel")]
|
||||
pub ai_embedding_model: Option<String>,
|
||||
#[serde(default, alias = "aiSystemPrompt")]
|
||||
@@ -85,7 +127,9 @@ pub struct PublicSiteSettingsResponse {
|
||||
pub social_email: Option<String>,
|
||||
pub location: Option<String>,
|
||||
pub tech_stack: Option<serde_json::Value>,
|
||||
pub music_playlist: Option<serde_json::Value>,
|
||||
pub ai_enabled: bool,
|
||||
pub paragraph_comments_enabled: bool,
|
||||
}
|
||||
|
||||
fn normalize_optional_string(value: Option<String>) -> Option<String> {
|
||||
@@ -103,82 +147,307 @@ fn normalize_optional_int(value: Option<i32>, min: i32, max: i32) -> Option<i32>
|
||||
value.map(|item| item.clamp(min, max))
|
||||
}
|
||||
|
||||
fn create_ai_provider_id() -> String {
|
||||
format!("provider-{}", Uuid::new_v4().simple())
|
||||
}
|
||||
|
||||
fn default_ai_provider_config() -> AiProviderConfig {
|
||||
AiProviderConfig {
|
||||
id: "default".to_string(),
|
||||
name: "默认提供商".to_string(),
|
||||
provider: ai::provider_name(None),
|
||||
api_base: Some(ai::default_api_base().to_string()),
|
||||
api_key: Some(ai::default_api_key().to_string()),
|
||||
chat_model: Some(ai::default_chat_model().to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_ai_provider_configs(items: Vec<AiProviderConfig>) -> Vec<AiProviderConfig> {
|
||||
let mut seen_ids = HashSet::new();
|
||||
|
||||
items
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, item)| {
|
||||
let provider = normalize_optional_string(Some(item.provider))
|
||||
.unwrap_or_else(|| ai::provider_name(None));
|
||||
let api_base = normalize_optional_string(item.api_base);
|
||||
let api_key = normalize_optional_string(item.api_key);
|
||||
let chat_model = normalize_optional_string(item.chat_model);
|
||||
let has_content = !item.name.trim().is_empty()
|
||||
|| !provider.trim().is_empty()
|
||||
|| api_base.is_some()
|
||||
|| api_key.is_some()
|
||||
|| chat_model.is_some();
|
||||
|
||||
if !has_content {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut id =
|
||||
normalize_optional_string(Some(item.id)).unwrap_or_else(create_ai_provider_id);
|
||||
if !seen_ids.insert(id.clone()) {
|
||||
id = create_ai_provider_id();
|
||||
seen_ids.insert(id.clone());
|
||||
}
|
||||
|
||||
let name = normalize_optional_string(Some(item.name))
|
||||
.unwrap_or_else(|| format!("提供商 {}", index + 1));
|
||||
|
||||
Some(AiProviderConfig {
|
||||
id,
|
||||
name,
|
||||
provider,
|
||||
api_base,
|
||||
api_key,
|
||||
chat_model,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn legacy_ai_provider_config(model: &Model) -> Option<AiProviderConfig> {
|
||||
let provider = normalize_optional_string(model.ai_provider.clone());
|
||||
let api_base = normalize_optional_string(model.ai_api_base.clone());
|
||||
let api_key = normalize_optional_string(model.ai_api_key.clone());
|
||||
let chat_model = normalize_optional_string(model.ai_chat_model.clone());
|
||||
|
||||
if provider.is_none() && api_base.is_none() && api_key.is_none() && chat_model.is_none() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(AiProviderConfig {
|
||||
id: "default".to_string(),
|
||||
name: "当前提供商".to_string(),
|
||||
provider: provider.unwrap_or_else(|| ai::provider_name(None)),
|
||||
api_base,
|
||||
api_key,
|
||||
chat_model,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn ai_provider_configs(model: &Model) -> Vec<AiProviderConfig> {
|
||||
let parsed = model
|
||||
.ai_providers
|
||||
.as_ref()
|
||||
.and_then(|value| serde_json::from_value::<Vec<AiProviderConfig>>(value.clone()).ok())
|
||||
.map(normalize_ai_provider_configs)
|
||||
.unwrap_or_default();
|
||||
|
||||
if !parsed.is_empty() {
|
||||
parsed
|
||||
} else {
|
||||
legacy_ai_provider_config(model).into_iter().collect()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn active_ai_provider_id(model: &Model) -> Option<String> {
|
||||
let configs = ai_provider_configs(model);
|
||||
let requested = normalize_optional_string(model.ai_active_provider_id.clone());
|
||||
|
||||
if let Some(active_id) = requested.filter(|id| configs.iter().any(|item| item.id == *id)) {
|
||||
Some(active_id)
|
||||
} else {
|
||||
configs.first().map(|item| item.id.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn write_ai_provider_state(
|
||||
model: &mut Model,
|
||||
configs: Vec<AiProviderConfig>,
|
||||
requested_active_id: Option<String>,
|
||||
) {
|
||||
let normalized = normalize_ai_provider_configs(configs);
|
||||
let active_id = requested_active_id
|
||||
.filter(|id| normalized.iter().any(|item| item.id == *id))
|
||||
.or_else(|| normalized.first().map(|item| item.id.clone()));
|
||||
|
||||
model.ai_providers = (!normalized.is_empty()).then(|| serde_json::json!(normalized.clone()));
|
||||
model.ai_active_provider_id = active_id.clone();
|
||||
|
||||
if let Some(active) = active_id.and_then(|id| normalized.into_iter().find(|item| item.id == id))
|
||||
{
|
||||
model.ai_provider = Some(active.provider);
|
||||
model.ai_api_base = active.api_base;
|
||||
model.ai_api_key = active.api_key;
|
||||
model.ai_chat_model = active.chat_model;
|
||||
} else {
|
||||
model.ai_provider = None;
|
||||
model.ai_api_base = None;
|
||||
model.ai_api_key = None;
|
||||
model.ai_chat_model = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_ai_provider_fields(model: &mut Model) {
|
||||
write_ai_provider_state(
|
||||
model,
|
||||
ai_provider_configs(model),
|
||||
active_ai_provider_id(model),
|
||||
);
|
||||
}
|
||||
|
||||
fn update_active_provider_from_legacy_fields(model: &mut Model) {
|
||||
let provider = model.ai_provider.clone();
|
||||
let api_base = model.ai_api_base.clone();
|
||||
let api_key = model.ai_api_key.clone();
|
||||
let chat_model = model.ai_chat_model.clone();
|
||||
let mut configs = ai_provider_configs(model);
|
||||
let active_id = active_ai_provider_id(model);
|
||||
|
||||
if configs.is_empty() {
|
||||
let mut config = default_ai_provider_config();
|
||||
config.provider = provider.unwrap_or_else(|| ai::provider_name(None));
|
||||
config.api_base = api_base;
|
||||
config.api_key = api_key;
|
||||
config.chat_model = chat_model;
|
||||
write_ai_provider_state(
|
||||
model,
|
||||
vec![config],
|
||||
Some(active_id.unwrap_or_else(|| "default".to_string())),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let target_id = active_id
|
||||
.clone()
|
||||
.or_else(|| configs.first().map(|item| item.id.clone()));
|
||||
|
||||
if let Some(target_id) = target_id {
|
||||
for config in &mut configs {
|
||||
if config.id == target_id {
|
||||
if let Some(next_provider) = provider.clone() {
|
||||
config.provider = next_provider;
|
||||
}
|
||||
config.api_base = api_base.clone();
|
||||
config.api_key = api_key.clone();
|
||||
config.chat_model = chat_model.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
write_ai_provider_state(model, configs, active_id);
|
||||
}
|
||||
|
||||
fn normalize_music_playlist(items: Vec<MusicTrackPayload>) -> Vec<MusicTrackPayload> {
|
||||
items
|
||||
.into_iter()
|
||||
.map(|item| MusicTrackPayload {
|
||||
title: item.title.trim().to_string(),
|
||||
artist: normalize_optional_string(item.artist),
|
||||
album: normalize_optional_string(item.album),
|
||||
url: item.url.trim().to_string(),
|
||||
cover_image_url: normalize_optional_string(item.cover_image_url),
|
||||
accent_color: normalize_optional_string(item.accent_color),
|
||||
description: normalize_optional_string(item.description),
|
||||
})
|
||||
.filter(|item| !item.title.is_empty() && !item.url.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl SiteSettingsPayload {
|
||||
pub(crate) fn apply(self, item: &mut ActiveModel) {
|
||||
pub(crate) fn apply(self, item: &mut Model) {
|
||||
if let Some(site_name) = self.site_name {
|
||||
item.site_name = Set(normalize_optional_string(Some(site_name)));
|
||||
item.site_name = normalize_optional_string(Some(site_name));
|
||||
}
|
||||
if let Some(site_short_name) = self.site_short_name {
|
||||
item.site_short_name = Set(normalize_optional_string(Some(site_short_name)));
|
||||
item.site_short_name = normalize_optional_string(Some(site_short_name));
|
||||
}
|
||||
if let Some(site_url) = self.site_url {
|
||||
item.site_url = Set(normalize_optional_string(Some(site_url)));
|
||||
item.site_url = normalize_optional_string(Some(site_url));
|
||||
}
|
||||
if let Some(site_title) = self.site_title {
|
||||
item.site_title = Set(normalize_optional_string(Some(site_title)));
|
||||
item.site_title = normalize_optional_string(Some(site_title));
|
||||
}
|
||||
if let Some(site_description) = self.site_description {
|
||||
item.site_description = Set(normalize_optional_string(Some(site_description)));
|
||||
item.site_description = normalize_optional_string(Some(site_description));
|
||||
}
|
||||
if let Some(hero_title) = self.hero_title {
|
||||
item.hero_title = Set(normalize_optional_string(Some(hero_title)));
|
||||
item.hero_title = normalize_optional_string(Some(hero_title));
|
||||
}
|
||||
if let Some(hero_subtitle) = self.hero_subtitle {
|
||||
item.hero_subtitle = Set(normalize_optional_string(Some(hero_subtitle)));
|
||||
item.hero_subtitle = normalize_optional_string(Some(hero_subtitle));
|
||||
}
|
||||
if let Some(owner_name) = self.owner_name {
|
||||
item.owner_name = Set(normalize_optional_string(Some(owner_name)));
|
||||
item.owner_name = normalize_optional_string(Some(owner_name));
|
||||
}
|
||||
if let Some(owner_title) = self.owner_title {
|
||||
item.owner_title = Set(normalize_optional_string(Some(owner_title)));
|
||||
item.owner_title = normalize_optional_string(Some(owner_title));
|
||||
}
|
||||
if let Some(owner_bio) = self.owner_bio {
|
||||
item.owner_bio = Set(normalize_optional_string(Some(owner_bio)));
|
||||
item.owner_bio = normalize_optional_string(Some(owner_bio));
|
||||
}
|
||||
if let Some(owner_avatar_url) = self.owner_avatar_url {
|
||||
item.owner_avatar_url = Set(normalize_optional_string(Some(owner_avatar_url)));
|
||||
item.owner_avatar_url = normalize_optional_string(Some(owner_avatar_url));
|
||||
}
|
||||
if let Some(social_github) = self.social_github {
|
||||
item.social_github = Set(normalize_optional_string(Some(social_github)));
|
||||
item.social_github = normalize_optional_string(Some(social_github));
|
||||
}
|
||||
if let Some(social_twitter) = self.social_twitter {
|
||||
item.social_twitter = Set(normalize_optional_string(Some(social_twitter)));
|
||||
item.social_twitter = normalize_optional_string(Some(social_twitter));
|
||||
}
|
||||
if let Some(social_email) = self.social_email {
|
||||
item.social_email = Set(normalize_optional_string(Some(social_email)));
|
||||
item.social_email = normalize_optional_string(Some(social_email));
|
||||
}
|
||||
if let Some(location) = self.location {
|
||||
item.location = Set(normalize_optional_string(Some(location)));
|
||||
item.location = normalize_optional_string(Some(location));
|
||||
}
|
||||
if let Some(tech_stack) = self.tech_stack {
|
||||
item.tech_stack = Set(Some(serde_json::json!(tech_stack)));
|
||||
item.tech_stack = Some(serde_json::json!(tech_stack));
|
||||
}
|
||||
if let Some(music_playlist) = self.music_playlist {
|
||||
item.music_playlist = Some(serde_json::json!(normalize_music_playlist(music_playlist)));
|
||||
}
|
||||
if let Some(ai_enabled) = self.ai_enabled {
|
||||
item.ai_enabled = Set(Some(ai_enabled));
|
||||
item.ai_enabled = Some(ai_enabled);
|
||||
}
|
||||
if let Some(paragraph_comments_enabled) = self.paragraph_comments_enabled {
|
||||
item.paragraph_comments_enabled = Some(paragraph_comments_enabled);
|
||||
}
|
||||
let provider_list_supplied = self.ai_providers.is_some();
|
||||
let provided_ai_providers = self.ai_providers.map(normalize_ai_provider_configs);
|
||||
let requested_active_provider_id = self
|
||||
.ai_active_provider_id
|
||||
.and_then(|value| normalize_optional_string(Some(value)));
|
||||
let legacy_provider_fields_updated = self.ai_provider.is_some()
|
||||
|| self.ai_api_base.is_some()
|
||||
|| self.ai_api_key.is_some()
|
||||
|| self.ai_chat_model.is_some();
|
||||
if let Some(ai_provider) = self.ai_provider {
|
||||
item.ai_provider = Set(normalize_optional_string(Some(ai_provider)));
|
||||
item.ai_provider = 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)));
|
||||
item.ai_api_base = 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)));
|
||||
item.ai_api_key = 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)));
|
||||
item.ai_chat_model = 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)));
|
||||
item.ai_embedding_model = 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)));
|
||||
item.ai_system_prompt = 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));
|
||||
item.ai_top_k = 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));
|
||||
item.ai_chunk_size = normalize_optional_int(self.ai_chunk_size, 400, 4000);
|
||||
}
|
||||
|
||||
if provider_list_supplied {
|
||||
write_ai_provider_state(
|
||||
item,
|
||||
provided_ai_providers.unwrap_or_default(),
|
||||
requested_active_provider_id.or_else(|| item.ai_active_provider_id.clone()),
|
||||
);
|
||||
} else if legacy_provider_fields_updated {
|
||||
update_active_provider_from_legacy_fields(item);
|
||||
} else {
|
||||
sync_ai_provider_fields(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -187,33 +456,76 @@ fn default_payload() -> SiteSettingsPayload {
|
||||
SiteSettingsPayload {
|
||||
site_name: Some("InitCool".to_string()),
|
||||
site_short_name: Some("Termi".to_string()),
|
||||
site_url: Some("https://termi.dev".to_string()),
|
||||
site_url: Some("https://init.cool".to_string()),
|
||||
site_title: Some("InitCool - 终端风格的内容平台".to_string()),
|
||||
site_description: Some("一个基于终端美学的个人内容站,记录代码、设计和生活。".to_string()),
|
||||
hero_title: Some("欢迎来到我的极客终端博客".to_string()),
|
||||
hero_subtitle: Some("这里记录技术、代码和生活点滴".to_string()),
|
||||
owner_name: Some("InitCool".to_string()),
|
||||
owner_title: Some("前端开发者 / 技术博主".to_string()),
|
||||
owner_title: Some("Rust / Go / Python Developer · Builder @ init.cool".to_string()),
|
||||
owner_bio: Some(
|
||||
"一名热爱技术的前端开发者,专注于构建高性能、优雅的用户界面。相信代码不仅是工具,更是一种艺术表达。"
|
||||
"InitCool,GitHub 用户名 limitcool。坚持不要重复造轮子,当前在维护 starter,平时主要写 Rust、Go、Python 相关项目,也在持续学习 AI 与 Web3。"
|
||||
.to_string(),
|
||||
),
|
||||
owner_avatar_url: None,
|
||||
social_github: Some("https://github.com".to_string()),
|
||||
social_twitter: Some("https://twitter.com".to_string()),
|
||||
social_email: Some("mailto:hello@termi.dev".to_string()),
|
||||
owner_avatar_url: Some("https://github.com/limitcool.png".to_string()),
|
||||
social_github: Some("https://github.com/limitcool".to_string()),
|
||||
social_twitter: None,
|
||||
social_email: Some("mailto:initcoool@gmail.com".to_string()),
|
||||
location: Some("Hong Kong".to_string()),
|
||||
tech_stack: Some(vec![
|
||||
"Astro".to_string(),
|
||||
"Rust".to_string(),
|
||||
"Go".to_string(),
|
||||
"Python".to_string(),
|
||||
"Svelte".to_string(),
|
||||
"Tailwind CSS".to_string(),
|
||||
"TypeScript".to_string(),
|
||||
"Astro".to_string(),
|
||||
"Loco.rs".to_string(),
|
||||
]),
|
||||
music_playlist: Some(vec![
|
||||
MusicTrackPayload {
|
||||
title: "山中来信".to_string(),
|
||||
artist: Some("InitCool Radio".to_string()),
|
||||
album: Some("站点默认歌单".to_string()),
|
||||
url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3".to_string(),
|
||||
cover_image_url: Some(
|
||||
"https://images.unsplash.com/photo-1510915228340-29c85a43dcfe?auto=format&fit=crop&w=600&q=80"
|
||||
.to_string(),
|
||||
),
|
||||
accent_color: Some("#2f6b5f".to_string()),
|
||||
description: Some("适合文章阅读时循环播放的轻氛围曲。".to_string()),
|
||||
},
|
||||
MusicTrackPayload {
|
||||
title: "风吹松声".to_string(),
|
||||
artist: Some("InitCool Radio".to_string()),
|
||||
album: Some("站点默认歌单".to_string()),
|
||||
url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3".to_string(),
|
||||
cover_image_url: Some(
|
||||
"https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=600&q=80"
|
||||
.to_string(),
|
||||
),
|
||||
accent_color: Some("#8a5b35".to_string()),
|
||||
description: Some("偏木质感的器乐氛围,适合深夜浏览。".to_string()),
|
||||
},
|
||||
MusicTrackPayload {
|
||||
title: "夜航小记".to_string(),
|
||||
artist: Some("InitCool Radio".to_string()),
|
||||
album: Some("站点默认歌单".to_string()),
|
||||
url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-3.mp3".to_string(),
|
||||
cover_image_url: Some(
|
||||
"https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?auto=format&fit=crop&w=600&q=80"
|
||||
.to_string(),
|
||||
),
|
||||
accent_color: Some("#375a7f".to_string()),
|
||||
description: Some("节奏更明显一点,适合切换阅读状态。".to_string()),
|
||||
},
|
||||
]),
|
||||
ai_enabled: Some(false),
|
||||
paragraph_comments_enabled: Some(true),
|
||||
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_providers: Some(vec![default_ai_provider_config()]),
|
||||
ai_active_provider_id: Some("default".to_string()),
|
||||
ai_embedding_model: Some(ai::local_embedding_label().to_string()),
|
||||
ai_system_prompt: Some(
|
||||
"你是这个博客的站内 AI 助手。请优先基于提供的上下文回答,答案要准确、简洁、实用;如果上下文不足,请明确说明。"
|
||||
@@ -233,12 +545,15 @@ pub(crate) async fn load_current(ctx: &AppContext) -> Result<Model> {
|
||||
return Ok(settings);
|
||||
}
|
||||
|
||||
let mut item = ActiveModel {
|
||||
let inserted = ActiveModel {
|
||||
id: Set(1),
|
||||
..Default::default()
|
||||
};
|
||||
default_payload().apply(&mut item);
|
||||
Ok(item.insert(&ctx.db).await?)
|
||||
}
|
||||
.insert(&ctx.db)
|
||||
.await?;
|
||||
let mut model = inserted;
|
||||
default_payload().apply(&mut model);
|
||||
Ok(model.into_active_model().update(&ctx.db).await?)
|
||||
}
|
||||
|
||||
fn public_response(model: Model) -> PublicSiteSettingsResponse {
|
||||
@@ -260,7 +575,9 @@ fn public_response(model: Model) -> PublicSiteSettingsResponse {
|
||||
social_email: model.social_email,
|
||||
location: model.location,
|
||||
tech_stack: model.tech_stack,
|
||||
music_playlist: model.music_playlist,
|
||||
ai_enabled: model.ai_enabled.unwrap_or(false),
|
||||
paragraph_comments_enabled: model.paragraph_comments_enabled.unwrap_or(true),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,8 +594,9 @@ pub async fn update(
|
||||
check_auth()?;
|
||||
|
||||
let current = load_current(&ctx).await?;
|
||||
let mut item = current.into_active_model();
|
||||
let mut item = current;
|
||||
params.apply(&mut item);
|
||||
let item = item.into_active_model();
|
||||
let updated = item.update(&ctx.db).await?;
|
||||
format::json(public_response(updated))
|
||||
}
|
||||
|
||||
@@ -63,7 +63,11 @@ pub async fn update(
|
||||
.filter(|value| !value.is_empty())
|
||||
!= Some(next_name)
|
||||
{
|
||||
content::rewrite_tag_references(previous_name.as_deref(), &previous_slug, Some(next_name))?;
|
||||
content::rewrite_tag_references(
|
||||
previous_name.as_deref(),
|
||||
&previous_slug,
|
||||
Some(next_name),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,48 +1,48 @@
|
||||
- id: 1
|
||||
pid: 1
|
||||
author: "Alice"
|
||||
email: "alice@example.com"
|
||||
content: "Great introduction! Looking forward to more content."
|
||||
author: "林川"
|
||||
email: "linchuan@example.com"
|
||||
content: "这篇做长文测试很合适,段落密度和古文节奏都不错。"
|
||||
approved: true
|
||||
|
||||
- id: 2
|
||||
pid: 1
|
||||
author: "Bob"
|
||||
email: "bob@example.com"
|
||||
content: "The terminal UI looks amazing. Love the design!"
|
||||
author: "阿青"
|
||||
email: "aqing@example.com"
|
||||
content: "建议后面再加几篇山水游记,方便测试问答检索是否能区分不同山名。"
|
||||
approved: true
|
||||
|
||||
- id: 3
|
||||
pid: 2
|
||||
author: "Charlie"
|
||||
email: "charlie@example.com"
|
||||
content: "Thanks for the Rust tips! The ownership concept finally clicked for me."
|
||||
author: "周宁"
|
||||
email: "zhouling@example.com"
|
||||
content: "这一段关于南岩和琼台的描写很好,适合测试段落评论锚点。"
|
||||
approved: true
|
||||
|
||||
- id: 4
|
||||
pid: 3
|
||||
author: "Diana"
|
||||
email: "diana@example.com"
|
||||
content: "Astro is indeed fast. I've been using it for my personal blog too."
|
||||
author: "顾远"
|
||||
email: "guyuan@example.com"
|
||||
content: "悬空寺这一段信息量很大,拿来测试 AI 摘要应该很有代表性。"
|
||||
approved: true
|
||||
|
||||
- id: 5
|
||||
pid: 4
|
||||
author: "Eve"
|
||||
email: "eve@example.com"
|
||||
content: "The color palette you shared is perfect. Using it for my terminal theme now!"
|
||||
author: "清嘉"
|
||||
email: "qingjia@example.com"
|
||||
content: "黄山记的序文很适合测试首屏摘要生成。"
|
||||
approved: true
|
||||
|
||||
- id: 6
|
||||
pid: 5
|
||||
author: "Frank"
|
||||
email: "frank@example.com"
|
||||
content: "Loco.rs looks promising. Might use it for my next project."
|
||||
author: "石霁"
|
||||
email: "shiji@example.com"
|
||||
content: "想看看评测页和文章页共存时,搜索能不能把这类古文结果排在前面。"
|
||||
approved: false
|
||||
|
||||
- id: 7
|
||||
pid: 2
|
||||
author: "Grace"
|
||||
email: "grace@example.com"
|
||||
content: "Would love to see more advanced Rust patterns in future posts."
|
||||
pid: 3
|
||||
author: "江禾"
|
||||
email: "jianghe@example.com"
|
||||
content: "如果后续要做段落评论,这篇恒山记很适合,因为章节分段比较清晰。"
|
||||
approved: true
|
||||
|
||||
@@ -1,38 +1,38 @@
|
||||
- id: 1
|
||||
site_name: "Tech Blog Daily"
|
||||
site_url: "https://techblog.example.com"
|
||||
avatar_url: "https://techblog.example.com/avatar.png"
|
||||
description: "Daily tech news and tutorials"
|
||||
category: "tech"
|
||||
site_name: "山中札记"
|
||||
site_url: "https://mountain-notes.example.com"
|
||||
avatar_url: "https://mountain-notes.example.com/avatar.png"
|
||||
description: "记录古籍、游记与自然地理的中文内容站。"
|
||||
category: "文化"
|
||||
status: "approved"
|
||||
|
||||
- id: 2
|
||||
site_name: "Rustacean Station"
|
||||
site_url: "https://rustacean.example.com"
|
||||
avatar_url: "https://rustacean.example.com/logo.png"
|
||||
description: "All things Rust programming"
|
||||
category: "tech"
|
||||
site_name: "旧书与远方"
|
||||
site_url: "https://oldbooks.example.com"
|
||||
avatar_url: "https://oldbooks.example.com/logo.png"
|
||||
description: "分享古典文学、读书笔记和旅行随笔。"
|
||||
category: "阅读"
|
||||
status: "approved"
|
||||
|
||||
- id: 3
|
||||
site_name: "Design Patterns"
|
||||
site_url: "https://designpatterns.example.com"
|
||||
avatar_url: "https://designpatterns.example.com/icon.png"
|
||||
description: "UI/UX design inspiration"
|
||||
category: "design"
|
||||
site_name: "山海数据局"
|
||||
site_url: "https://shanhai-data.example.com"
|
||||
avatar_url: "https://shanhai-data.example.com/icon.png"
|
||||
description: "偏技术向的中文站点,关注搜索、知识库与可视化。"
|
||||
category: "技术"
|
||||
status: "approved"
|
||||
|
||||
- id: 4
|
||||
site_name: "Code Snippets"
|
||||
site_url: "https://codesnippets.example.com"
|
||||
description: "Useful code snippets for developers"
|
||||
category: "dev"
|
||||
site_name: "风物手册"
|
||||
site_url: "https://fengwu.example.com"
|
||||
description: "整理地方风物、古迹与旅行地图。"
|
||||
category: "旅行"
|
||||
status: "pending"
|
||||
|
||||
- id: 5
|
||||
site_name: "Web Dev Weekly"
|
||||
site_url: "https://webdevweekly.example.com"
|
||||
avatar_url: "https://webdevweekly.example.com/favicon.png"
|
||||
description: "Weekly web development newsletter"
|
||||
category: "dev"
|
||||
site_name: "慢读周刊"
|
||||
site_url: "https://slowread.example.com"
|
||||
avatar_url: "https://slowread.example.com/favicon.png"
|
||||
description: "每周推荐中文长文、读书摘录与站点发现。"
|
||||
category: "内容"
|
||||
status: "pending"
|
||||
|
||||
@@ -1,191 +1,109 @@
|
||||
- id: 1
|
||||
pid: 1
|
||||
title: "Welcome to Termi Blog"
|
||||
title: "徐霞客游记·游太和山日记(上)"
|
||||
slug: "welcome-to-termi"
|
||||
content: |
|
||||
# Welcome to Termi Blog
|
||||
# 徐霞客游记·游太和山日记(上)
|
||||
|
||||
This is the first post on our new blog built with Astro and Loco.rs backend.
|
||||
登仙猿岭。十馀里,有枯溪小桥,为郧县境,乃河南、湖广界。东五里,有池一泓,曰青泉,上源不见所自来,而下流淙淙,地又属淅川。
|
||||
|
||||
## Features
|
||||
自此连逾山岭,桃李缤纷,山花夹道,幽艳异常。山坞之中,居庐相望,沿流稻畦,高下鳞次,不似山、陕间矣。
|
||||
|
||||
- 🚀 Fast performance with Astro
|
||||
- 🎨 Terminal-style UI design
|
||||
- 💬 Comments system
|
||||
- 🔗 Friend links
|
||||
- 🏷️ Tags and categories
|
||||
|
||||
## Code Example
|
||||
|
||||
```rust
|
||||
fn main() {
|
||||
println!("Hello, Termi!");
|
||||
}
|
||||
```
|
||||
|
||||
Stay tuned for more posts!
|
||||
excerpt: "Welcome to our new blog built with Astro and Loco.rs backend."
|
||||
category: "general"
|
||||
骑而南趋,石道平敞。三十里,越一石梁,有溪自西东注,即太和下流入汉者。越桥为迎恩宫,西向。前有碑大书“第一山”三字,乃米襄阳笔。
|
||||
excerpt: "《徐霞客游记》太和山上篇,适合作为中文长文测试样本。"
|
||||
category: "古籍游记"
|
||||
published: true
|
||||
pinned: true
|
||||
tags:
|
||||
- welcome
|
||||
- astro
|
||||
- loco-rs
|
||||
- 徐霞客
|
||||
- 游记
|
||||
- 太和山
|
||||
- 长文测试
|
||||
|
||||
- id: 2
|
||||
pid: 2
|
||||
title: "Rust Programming Tips"
|
||||
slug: "rust-programming-tips"
|
||||
title: "徐霞客游记·游太和山日记(下)"
|
||||
slug: "building-blog-with-astro"
|
||||
content: |
|
||||
# Rust Programming Tips
|
||||
# 徐霞客游记·游太和山日记(下)
|
||||
|
||||
Here are some essential tips for Rust developers:
|
||||
更衣上金顶。瞻叩毕,天宇澄朗,下瞰诸峰,近者鹄峙,远者罗列,诚天真奥区也。
|
||||
|
||||
## 1. Ownership and Borrowing
|
||||
遂从三天门之右小径下峡中。此径无级无索,乱峰离立,路穿其间,迥觉幽胜。三里馀,抵蜡烛峰右,泉涓涓溢出路旁,下为蜡烛涧。
|
||||
|
||||
Understanding ownership is crucial in Rust. Every value has an owner, and there can only be one owner at a time.
|
||||
|
||||
## 2. Pattern Matching
|
||||
|
||||
Use `match` expressions for exhaustive pattern matching:
|
||||
|
||||
```rust
|
||||
match result {
|
||||
Ok(value) => println!("Success: {}", value),
|
||||
Err(e) => println!("Error: {}", e),
|
||||
}
|
||||
```
|
||||
|
||||
## 3. Error Handling
|
||||
|
||||
Use `Result` and `Option` types effectively with the `?` operator.
|
||||
|
||||
Happy coding!
|
||||
excerpt: "Essential tips for Rust developers including ownership, pattern matching, and error handling."
|
||||
category: "tech"
|
||||
从宫左趋雷公洞。洞在悬崖间。乃从北天门下,一径阴森,滴水、仙侣二岩,俱在路左,飞崖上突,泉滴沥于中。
|
||||
excerpt: "《徐霞客游记》太和山下篇,包含琼台、南岩与五龙宫等段落。"
|
||||
category: "古籍游记"
|
||||
published: true
|
||||
pinned: false
|
||||
tags:
|
||||
- rust
|
||||
- programming
|
||||
- tips
|
||||
- 徐霞客
|
||||
- 游记
|
||||
- 太和山
|
||||
- 长文测试
|
||||
|
||||
- id: 3
|
||||
pid: 3
|
||||
title: "Building a Blog with Astro"
|
||||
slug: "building-blog-with-astro"
|
||||
title: "徐霞客游记·游恒山日记"
|
||||
slug: "rust-programming-tips"
|
||||
content: |
|
||||
# Building a Blog with Astro
|
||||
# 徐霞客游记·游恒山日记
|
||||
|
||||
Astro is a modern static site generator that delivers lightning-fast performance.
|
||||
出南山。大溪从山中俱来者,别而西去。余北驰平陆中,望外界之山,高不及台山十之四,其长缭绕如垣。
|
||||
|
||||
## Why Astro?
|
||||
余溯西涧入,又一涧自北来,遂从其西登岭,道甚峻。北向直上者六七里,西转,又北跻而上者五六里,登峰两重,造其巅,是名箭筸岭。
|
||||
|
||||
- **Zero JavaScript by default**: Ships less JavaScript to the client
|
||||
- **Island Architecture**: Hydrate only interactive components
|
||||
- **Framework Agnostic**: Use React, Vue, Svelte, or vanilla JS
|
||||
- **Great DX**: Excellent developer experience with hot module replacement
|
||||
|
||||
## Getting Started
|
||||
|
||||
```bash
|
||||
npm create astro@latest
|
||||
cd my-astro-project
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Conclusion
|
||||
|
||||
Astro is perfect for content-focused websites like blogs.
|
||||
excerpt: "Learn why Astro is the perfect choice for building fast, content-focused blogs."
|
||||
category: "tech"
|
||||
三转,峡愈隘,崖愈高。西崖之半,层楼高悬,曲榭斜倚,望之如蜃吐重台者,悬空寺也。
|
||||
excerpt: "游恒山、悬空寺与北岳登顶的古文纪行,适合做中文长文测试。"
|
||||
category: "古籍游记"
|
||||
published: true
|
||||
pinned: false
|
||||
tags:
|
||||
- astro
|
||||
- web-dev
|
||||
- static-site
|
||||
- 徐霞客
|
||||
- 恒山
|
||||
- 悬空寺
|
||||
- 长文测试
|
||||
|
||||
- id: 4
|
||||
pid: 4
|
||||
title: "Terminal UI Design Principles"
|
||||
title: "游黄山记(上)"
|
||||
slug: "terminal-ui-design"
|
||||
content: |
|
||||
# Terminal UI Design Principles
|
||||
# 游黄山记(上)
|
||||
|
||||
Terminal-style interfaces are making a comeback in modern web design.
|
||||
辛巳春,余与程孟阳订黄山之游,约以梅花时相寻于武林之西溪。徐维翰书来劝驾,读之两腋欲举,遂挟吴去尘以行。
|
||||
|
||||
## Key Elements
|
||||
黄山耸秀峻极,作镇一方。江南诸山,天台、天目为最,以地形准之,黄山之趾与二山齐。
|
||||
|
||||
1. **Monospace Fonts**: Use fonts like Fira Code, JetBrains Mono
|
||||
2. **Dark Themes**: Black or dark backgrounds with vibrant text colors
|
||||
3. **Command Prompts**: Use `$` or `>` as visual indicators
|
||||
4. **ASCII Art**: Decorative elements using text characters
|
||||
5. **Blinking Cursor**: The iconic terminal cursor
|
||||
|
||||
## Color Palette
|
||||
|
||||
- Background: `#0d1117`
|
||||
- Text: `#c9d1d9`
|
||||
- Accent: `#58a6ff`
|
||||
- Success: `#3fb950`
|
||||
- Warning: `#d29922`
|
||||
- Error: `#f85149`
|
||||
|
||||
## Implementation
|
||||
|
||||
Use CSS to create the terminal aesthetic while maintaining accessibility.
|
||||
excerpt: "Learn the key principles of designing beautiful terminal-style user interfaces."
|
||||
category: "design"
|
||||
自山口至汤口,山之麓也,登山之径于是始。汤泉之流,自紫石峰六百仞县布,其下有香泉溪。
|
||||
excerpt: "钱谦益《游黄山记》上篇,包含序、记之一与记之二。"
|
||||
category: "古籍游记"
|
||||
published: true
|
||||
pinned: false
|
||||
tags:
|
||||
- design
|
||||
- terminal
|
||||
- ui
|
||||
- 钱谦益
|
||||
- 黄山
|
||||
- 游记
|
||||
- 长文测试
|
||||
|
||||
- id: 5
|
||||
pid: 5
|
||||
title: "Loco.rs Backend Framework"
|
||||
title: "游黄山记(中)"
|
||||
slug: "loco-rs-framework"
|
||||
content: |
|
||||
# Introduction to Loco.rs
|
||||
# 游黄山记(中)
|
||||
|
||||
Loco.rs is a web and API framework for Rust inspired by Rails.
|
||||
由祥符寺度石桥而北,逾慈光寺,行数里,径朱砂庵而上。过此取道钵盂、老人两峰之间,峰趾相并,两崖合遝,弥望削成。
|
||||
|
||||
## Features
|
||||
憩桃源庵,指天都为诸峰之中峰,山形络绎,未有以殊异也。云生峰腰,层叠如裼衣焉。
|
||||
|
||||
- **MVC Architecture**: Model-View-Controller pattern
|
||||
- **SeaORM Integration**: Powerful ORM for database operations
|
||||
- **Background Jobs**: Built-in job processing
|
||||
- **Authentication**: Ready-to-use auth system
|
||||
- **CLI Generator**: Scaffold resources quickly
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
cargo install loco
|
||||
loco new myapp
|
||||
cd myapp
|
||||
cargo loco start
|
||||
```
|
||||
|
||||
## Why Loco.rs?
|
||||
|
||||
- Opinionated but flexible
|
||||
- Production-ready defaults
|
||||
- Excellent documentation
|
||||
- Active community
|
||||
|
||||
Perfect for building APIs and web applications in Rust.
|
||||
excerpt: "An introduction to Loco.rs, the Rails-inspired web framework for Rust."
|
||||
category: "tech"
|
||||
清晓,出文殊院,神鸦背行而先。避莲华沟险,从支径右折,险益甚。上平天矼,转始信峰,经散花坞,看扰龙松。
|
||||
excerpt: "钱谦益《游黄山记》中篇,适合测试中文长文、检索与段落锚点。"
|
||||
category: "古籍游记"
|
||||
published: true
|
||||
pinned: false
|
||||
tags:
|
||||
- rust
|
||||
- loco-rs
|
||||
- backend
|
||||
- api
|
||||
- 钱谦益
|
||||
- 黄山
|
||||
- 游记
|
||||
- 长文测试
|
||||
|
||||
@@ -1,59 +1,59 @@
|
||||
- id: 1
|
||||
title: "塞尔达传说:王国之泪"
|
||||
review_type: "game"
|
||||
rating: 5
|
||||
review_date: "2024-03-20"
|
||||
status: "completed"
|
||||
description: "开放世界的巅峰之作,究极手能力带来无限创意空间"
|
||||
tags: ["Switch", "开放世界", "冒险"]
|
||||
cover: "🎮"
|
||||
|
||||
- id: 2
|
||||
title: "进击的巨人"
|
||||
review_type: "anime"
|
||||
rating: 5
|
||||
review_date: "2023-11-10"
|
||||
status: "completed"
|
||||
description: "史诗级完结,剧情反转令人震撼"
|
||||
tags: ["热血", "悬疑", "神作"]
|
||||
cover: "🎭"
|
||||
|
||||
- id: 3
|
||||
title: "赛博朋克 2077"
|
||||
review_type: "game"
|
||||
rating: 4
|
||||
review_date: "2024-01-15"
|
||||
status: "completed"
|
||||
description: "夜之城的故事,虽然首发有问题但后续更新很棒"
|
||||
tags: ["PC", "RPG", "科幻"]
|
||||
cover: "🎮"
|
||||
|
||||
- id: 4
|
||||
title: "三体"
|
||||
review_type: "book"
|
||||
rating: 5
|
||||
review_date: "2023-08-05"
|
||||
status: "completed"
|
||||
description: "硬科幻巅峰,宇宙社会学的黑暗森林法则"
|
||||
tags: ["科幻", "经典", "雨果奖"]
|
||||
cover: "📚"
|
||||
|
||||
- id: 5
|
||||
title: "星际穿越"
|
||||
title: "《漫长的季节》"
|
||||
review_type: "movie"
|
||||
rating: 5
|
||||
review_date: "2024-02-14"
|
||||
status: "completed"
|
||||
description: "诺兰神作,五维空间和黑洞的视觉奇观"
|
||||
tags: ["科幻", "IMAX", "诺兰"]
|
||||
cover: "🎬"
|
||||
review_date: "2024-03-20"
|
||||
status: "published"
|
||||
description: "极有质感的中文悬疑剧,人物命运与时代氛围都很扎实。"
|
||||
tags: ["国产剧", "悬疑", "年度推荐"]
|
||||
cover: "/review-covers/the-long-season.svg"
|
||||
|
||||
- id: 6
|
||||
title: "博德之门3"
|
||||
- id: 2
|
||||
title: "《十三邀》"
|
||||
review_type: "movie"
|
||||
rating: 4
|
||||
review_date: "2024-01-10"
|
||||
status: "published"
|
||||
description: "更像一组人物观察样本,适合慢慢看,不适合倍速。"
|
||||
tags: ["访谈", "人文", "纪实"]
|
||||
cover: "/review-covers/thirteen-invites.svg"
|
||||
|
||||
- id: 3
|
||||
title: "《黑神话:悟空》"
|
||||
review_type: "game"
|
||||
rating: 5
|
||||
review_date: "2024-04-01"
|
||||
status: "in-progress"
|
||||
description: "CRPG的文艺复兴,骰子决定命运"
|
||||
tags: ["PC", "CRPG", "多人"]
|
||||
cover: "🎮"
|
||||
review_date: "2024-08-25"
|
||||
status: "published"
|
||||
description: "美术和演出都很强,战斗手感也足够扎实,是非常好的中文游戏样本。"
|
||||
tags: ["国产游戏", "动作", "神话"]
|
||||
cover: "/review-covers/black-myth-wukong.svg"
|
||||
|
||||
- id: 4
|
||||
title: "《置身事内》"
|
||||
review_type: "book"
|
||||
rating: 5
|
||||
review_date: "2024-02-18"
|
||||
status: "published"
|
||||
description: "把很多宏观经济问题讲得非常清楚,适合做深阅读测试。"
|
||||
tags: ["经济", "非虚构", "中国"]
|
||||
cover: "/review-covers/placed-within.svg"
|
||||
|
||||
- id: 5
|
||||
title: "《宇宙探索编辑部》"
|
||||
review_type: "movie"
|
||||
rating: 4
|
||||
review_date: "2024-04-12"
|
||||
status: "published"
|
||||
description: "荒诞和真诚并存,气质很特别,很适合作为中文评论内容。"
|
||||
tags: ["电影", "科幻", "荒诞"]
|
||||
cover: "/review-covers/journey-to-the-west-editorial.svg"
|
||||
|
||||
- id: 6
|
||||
title: "《疲惫生活中的英雄梦想》"
|
||||
review_type: "music"
|
||||
rating: 4
|
||||
review_date: "2024-05-01"
|
||||
status: "draft"
|
||||
description: "适合深夜循环,文字和旋律都带一点诚恳的钝感。"
|
||||
tags: ["音乐", "中文", "独立"]
|
||||
cover: "/review-covers/hero-dreams-in-tired-life.svg"
|
||||
|
||||
@@ -1,30 +1,55 @@
|
||||
- id: 1
|
||||
site_name: "InitCool"
|
||||
site_short_name: "Termi"
|
||||
site_url: "https://termi.dev"
|
||||
site_title: "InitCool - 终端风格的内容平台"
|
||||
site_description: "一个基于终端美学的个人内容站,记录代码、设计和生活。"
|
||||
hero_title: "欢迎来到我的极客终端博客"
|
||||
hero_subtitle: "这里记录技术、代码和生活点滴"
|
||||
site_url: "https://init.cool"
|
||||
site_title: "InitCool · 中文长文与 AI 搜索实验站"
|
||||
site_description: "一个偏终端审美的中文内容站,用来测试文章检索、AI 问答、段落评论与后台工作流。"
|
||||
hero_title: "欢迎来到我的中文内容实验站"
|
||||
hero_subtitle: "这里有长文章、评测、友链,以及逐步打磨中的 AI 搜索体验"
|
||||
owner_name: "InitCool"
|
||||
owner_title: "前端开发者 / 技术博主"
|
||||
owner_bio: "一名热爱技术的前端开发者,专注于构建高性能、优雅的用户界面。相信代码不仅是工具,更是一种艺术表达。"
|
||||
owner_avatar_url: ""
|
||||
social_github: "https://github.com"
|
||||
social_twitter: "https://twitter.com"
|
||||
social_email: "mailto:hello@termi.dev"
|
||||
location: "Hong Kong"
|
||||
owner_title: "Rust / Go / Python Developer · Builder @ init.cool"
|
||||
owner_bio: "InitCool,GitHub 用户名 limitcool。坚持不要重复造轮子,当前在维护 starter,平时主要写 Rust、Go、Python 相关项目,也在持续学习 AI 与 Web3。"
|
||||
owner_avatar_url: "https://github.com/limitcool.png"
|
||||
social_github: "https://github.com/limitcool"
|
||||
social_twitter: ""
|
||||
social_email: "mailto:initcoool@gmail.com"
|
||||
location: "中国香港"
|
||||
tech_stack:
|
||||
- "Astro"
|
||||
- "Rust"
|
||||
- "Go"
|
||||
- "Python"
|
||||
- "Svelte"
|
||||
- "Tailwind CSS"
|
||||
- "TypeScript"
|
||||
- "Astro"
|
||||
- "Loco.rs"
|
||||
music_playlist:
|
||||
- title: "山中来信"
|
||||
artist: "InitCool Radio"
|
||||
album: "站点默认歌单"
|
||||
url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3"
|
||||
cover_image_url: "https://images.unsplash.com/photo-1510915228340-29c85a43dcfe?auto=format&fit=crop&w=600&q=80"
|
||||
accent_color: "#2f6b5f"
|
||||
description: "适合文章阅读时循环播放的轻氛围曲。"
|
||||
- title: "风吹松声"
|
||||
artist: "InitCool Radio"
|
||||
album: "站点默认歌单"
|
||||
url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3"
|
||||
cover_image_url: "https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=600&q=80"
|
||||
accent_color: "#8a5b35"
|
||||
description: "偏木质感的器乐氛围,适合深夜浏览。"
|
||||
- title: "夜航小记"
|
||||
artist: "InitCool Radio"
|
||||
album: "站点默认歌单"
|
||||
url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-3.mp3"
|
||||
cover_image_url: "https://images.unsplash.com/photo-1493225457124-a3eb161ffa5f?auto=format&fit=crop&w=600&q=80"
|
||||
accent_color: "#375a7f"
|
||||
description: "节奏更明显一点,适合切换阅读状态。"
|
||||
ai_enabled: false
|
||||
paragraph_comments_enabled: true
|
||||
ai_provider: "newapi"
|
||||
ai_api_base: "http://localhost:8317/v1"
|
||||
ai_api_key: "your-api-key-1"
|
||||
ai_api_base: "https://91code.jiangnight.com/v1"
|
||||
ai_api_key: "sk-5a5e27db9fb8f8ee7e1d8e3c6a44638c2e50cdb0a0cf9d926fefb5418ff62571"
|
||||
ai_chat_model: "gpt-5.4"
|
||||
ai_embedding_model: "fastembed / local all-MiniLM-L6-v2"
|
||||
ai_system_prompt: "你是这个博客的站内 AI 助手。请优先基于提供的上下文回答,答案要准确、简洁、实用;如果上下文不足,请明确说明。"
|
||||
ai_system_prompt: "你是这个博客的站内 AI 助手。请优先依据检索到的站内内容回答问题,回答保持准确、简洁、清晰;如果上下文不足,请明确说明,不要编造。"
|
||||
ai_top_k: 4
|
||||
ai_chunk_size: 1200
|
||||
|
||||
@@ -57,10 +57,20 @@ fn is_blank(value: &Option<String>) -> bool {
|
||||
}
|
||||
|
||||
fn matches_legacy_ai_defaults(settings: &site_settings::Model) -> bool {
|
||||
settings.ai_provider.as_deref().map(str::trim) == Some("openai-compatible")
|
||||
&& settings.ai_api_base.as_deref().map(str::trim) == Some("https://api.openai.com/v1")
|
||||
&& settings.ai_chat_model.as_deref().map(str::trim) == Some("gpt-4.1-mini")
|
||||
&& is_blank(&settings.ai_api_key)
|
||||
let provider = settings.ai_provider.as_deref().map(str::trim);
|
||||
let api_base = settings.ai_api_base.as_deref().map(str::trim);
|
||||
let chat_model = settings.ai_chat_model.as_deref().map(str::trim);
|
||||
|
||||
(provider == Some("openai-compatible")
|
||||
&& api_base == Some("https://api.openai.com/v1")
|
||||
&& chat_model == Some("gpt-4.1-mini")
|
||||
&& is_blank(&settings.ai_api_key))
|
||||
|| (provider == Some("newapi")
|
||||
&& matches!(
|
||||
api_base,
|
||||
Some("https://cliproxy.ai.init.cool") | Some("https://cliproxy.ai.init.cool/v1")
|
||||
)
|
||||
&& chat_model == Some("gpt-5.4"))
|
||||
}
|
||||
|
||||
async fn sync_site_settings(ctx: &AppContext, base: &Path) -> Result<()> {
|
||||
@@ -80,6 +90,27 @@ async fn sync_site_settings(ctx: &AppContext, base: &Path) -> Result<()> {
|
||||
})
|
||||
.filter(|items| !items.is_empty())
|
||||
.map(|items| serde_json::json!(items));
|
||||
let music_playlist = seed["music_playlist"]
|
||||
.as_array()
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
let title = item["title"].as_str()?.trim();
|
||||
let url = item["url"].as_str()?.trim();
|
||||
if title.is_empty() || url.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(serde_json::json!({
|
||||
"title": title,
|
||||
"url": url,
|
||||
}))
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.filter(|items| !items.is_empty())
|
||||
.map(serde_json::Value::Array);
|
||||
|
||||
let existing = site_settings::Entity::find()
|
||||
.order_by_asc(site_settings::Column::Id)
|
||||
@@ -138,9 +169,16 @@ async fn sync_site_settings(ctx: &AppContext, base: &Path) -> Result<()> {
|
||||
if existing.tech_stack.is_none() {
|
||||
model.tech_stack = Set(tech_stack);
|
||||
}
|
||||
if existing.music_playlist.is_none() {
|
||||
model.music_playlist = Set(music_playlist);
|
||||
}
|
||||
if existing.ai_enabled.is_none() {
|
||||
model.ai_enabled = Set(seed["ai_enabled"].as_bool());
|
||||
}
|
||||
if existing.paragraph_comments_enabled.is_none() {
|
||||
model.paragraph_comments_enabled =
|
||||
Set(seed["paragraph_comments_enabled"].as_bool().or(Some(true)));
|
||||
}
|
||||
if should_upgrade_legacy_ai_defaults {
|
||||
model.ai_provider = Set(as_optional_string(&seed["ai_provider"]));
|
||||
model.ai_api_base = Set(as_optional_string(&seed["ai_api_base"]));
|
||||
@@ -194,7 +232,11 @@ async fn sync_site_settings(ctx: &AppContext, base: &Path) -> Result<()> {
|
||||
social_email: Set(as_optional_string(&seed["social_email"])),
|
||||
location: Set(as_optional_string(&seed["location"])),
|
||||
tech_stack: Set(tech_stack),
|
||||
music_playlist: Set(music_playlist),
|
||||
ai_enabled: Set(seed["ai_enabled"].as_bool()),
|
||||
paragraph_comments_enabled: Set(seed["paragraph_comments_enabled"]
|
||||
.as_bool()
|
||||
.or(Some(true))),
|
||||
ai_provider: Set(as_optional_string(&seed["ai_provider"])),
|
||||
ai_api_base: Set(as_optional_string(&seed["ai_api_base"])),
|
||||
ai_api_key: Set(as_optional_string(&seed["ai_api_key"])),
|
||||
|
||||
@@ -20,6 +20,8 @@ pub struct Model {
|
||||
pub tags: Option<Json>,
|
||||
pub post_type: Option<String>,
|
||||
pub image: Option<String>,
|
||||
#[sea_orm(column_type = "JsonBinary", nullable)]
|
||||
pub images: Option<Json>,
|
||||
pub pinned: Option<bool>,
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ pub struct Model {
|
||||
pub description: Option<String>,
|
||||
pub tags: Option<String>,
|
||||
pub cover: Option<String>,
|
||||
pub link_url: Option<String>,
|
||||
pub created_at: DateTimeWithTimeZone,
|
||||
pub updated_at: DateTimeWithTimeZone,
|
||||
}
|
||||
|
||||
@@ -28,12 +28,18 @@ pub struct Model {
|
||||
pub location: Option<String>,
|
||||
#[sea_orm(column_type = "JsonBinary", nullable)]
|
||||
pub tech_stack: Option<Json>,
|
||||
#[sea_orm(column_type = "JsonBinary", nullable)]
|
||||
pub music_playlist: Option<Json>,
|
||||
pub ai_enabled: Option<bool>,
|
||||
pub paragraph_comments_enabled: Option<bool>,
|
||||
pub ai_provider: Option<String>,
|
||||
pub ai_api_base: Option<String>,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub ai_api_key: Option<String>,
|
||||
pub ai_chat_model: Option<String>,
|
||||
#[sea_orm(column_type = "JsonBinary", nullable)]
|
||||
pub ai_providers: Option<Json>,
|
||||
pub ai_active_provider_id: Option<String>,
|
||||
pub ai_embedding_model: Option<String>,
|
||||
#[sea_orm(column_type = "Text", nullable)]
|
||||
pub ai_system_prompt: Option<String>,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, Condition, EntityTrait, IntoActiveModel, QueryFilter,
|
||||
QueryOrder, Set,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -18,12 +18,21 @@ struct MarkdownFrontmatter {
|
||||
title: Option<String>,
|
||||
slug: Option<String>,
|
||||
description: Option<String>,
|
||||
category: Option<String>,
|
||||
#[serde(
|
||||
default,
|
||||
alias = "category",
|
||||
alias = "categories",
|
||||
deserialize_with = "deserialize_optional_string_list"
|
||||
)]
|
||||
categories: Option<Vec<String>>,
|
||||
#[serde(default, deserialize_with = "deserialize_optional_string_list")]
|
||||
tags: Option<Vec<String>>,
|
||||
post_type: Option<String>,
|
||||
image: Option<String>,
|
||||
images: Option<Vec<String>>,
|
||||
pinned: Option<bool>,
|
||||
published: Option<bool>,
|
||||
draft: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -36,6 +45,7 @@ pub struct MarkdownPost {
|
||||
pub tags: Vec<String>,
|
||||
pub post_type: String,
|
||||
pub image: Option<String>,
|
||||
pub images: Vec<String>,
|
||||
pub pinned: bool,
|
||||
pub published: bool,
|
||||
pub file_path: String,
|
||||
@@ -51,6 +61,7 @@ pub struct MarkdownPostDraft {
|
||||
pub tags: Vec<String>,
|
||||
pub post_type: String,
|
||||
pub image: Option<String>,
|
||||
pub images: Vec<String>,
|
||||
pub pinned: bool,
|
||||
pub published: bool,
|
||||
}
|
||||
@@ -104,13 +115,71 @@ fn trim_to_option(input: Option<String>) -> Option<String> {
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_string_list(values: Option<Vec<String>>) -> Vec<String> {
|
||||
values
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|item| item.trim().to_string())
|
||||
.filter(|item| !item.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn split_inline_list(value: &str) -> Vec<String> {
|
||||
value
|
||||
.split([',', ','])
|
||||
.map(|item| item.trim().to_string())
|
||||
.filter(|item| !item.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn deserialize_optional_string_list<'de, D>(
|
||||
deserializer: D,
|
||||
) -> std::result::Result<Option<Vec<String>>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let raw = Option::<serde_yaml::Value>::deserialize(deserializer)?;
|
||||
|
||||
match raw {
|
||||
None | Some(serde_yaml::Value::Null) => Ok(None),
|
||||
Some(serde_yaml::Value::String(value)) => {
|
||||
let items = split_inline_list(&value);
|
||||
if items.is_empty() && !value.trim().is_empty() {
|
||||
Ok(Some(vec![value.trim().to_string()]))
|
||||
} else if items.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(items))
|
||||
}
|
||||
}
|
||||
Some(serde_yaml::Value::Sequence(items)) => Ok(Some(
|
||||
items
|
||||
.into_iter()
|
||||
.filter_map(|item| match item {
|
||||
serde_yaml::Value::String(value) => {
|
||||
let trimmed = value.trim().to_string();
|
||||
(!trimmed.is_empty()).then_some(trimmed)
|
||||
}
|
||||
serde_yaml::Value::Number(value) => Some(value.to_string()),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
)),
|
||||
Some(other) => Err(serde::de::Error::custom(format!(
|
||||
"unsupported frontmatter list value: {other:?}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn slugify(value: &str) -> String {
|
||||
let mut slug = String::new();
|
||||
let mut last_was_dash = false;
|
||||
|
||||
for ch in value.trim().chars() {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
slug.push(ch.to_ascii_lowercase());
|
||||
if ch.is_alphanumeric() {
|
||||
for lower in ch.to_lowercase() {
|
||||
slug.push(lower);
|
||||
}
|
||||
last_was_dash = false;
|
||||
} else if (ch.is_whitespace() || ch == '-' || ch == '_') && !last_was_dash {
|
||||
slug.push('-');
|
||||
@@ -208,7 +277,9 @@ fn parse_markdown_source(file_stem: &str, raw: &str, file_path: &str) -> Result<
|
||||
.unwrap_or_else(|| slug.clone());
|
||||
let description =
|
||||
trim_to_option(frontmatter.description.clone()).or_else(|| excerpt_from_content(&content));
|
||||
let category = trim_to_option(frontmatter.category.clone());
|
||||
let category = normalize_string_list(frontmatter.categories.clone())
|
||||
.into_iter()
|
||||
.next();
|
||||
let tags = frontmatter
|
||||
.tags
|
||||
.unwrap_or_default()
|
||||
@@ -227,8 +298,11 @@ fn parse_markdown_source(file_stem: &str, raw: &str, file_path: &str) -> Result<
|
||||
post_type: trim_to_option(frontmatter.post_type.clone())
|
||||
.unwrap_or_else(|| "article".to_string()),
|
||||
image: trim_to_option(frontmatter.image.clone()),
|
||||
images: normalize_string_list(frontmatter.images.clone()),
|
||||
pinned: frontmatter.pinned.unwrap_or(false),
|
||||
published: frontmatter.published.unwrap_or(true),
|
||||
published: frontmatter
|
||||
.published
|
||||
.unwrap_or(!frontmatter.draft.unwrap_or(false)),
|
||||
file_path: file_path.to_string(),
|
||||
})
|
||||
}
|
||||
@@ -266,6 +340,13 @@ fn build_markdown_document(post: &MarkdownPost) -> String {
|
||||
lines.push(format!("image: {}", image));
|
||||
}
|
||||
|
||||
if !post.images.is_empty() {
|
||||
lines.push("images:".to_string());
|
||||
for image in &post.images {
|
||||
lines.push(format!(" - {}", image));
|
||||
}
|
||||
}
|
||||
|
||||
if !post.tags.is_empty() {
|
||||
lines.push("tags:".to_string());
|
||||
for tag in &post.tags {
|
||||
@@ -307,6 +388,7 @@ fn ensure_markdown_posts_bootstrapped() -> Result<()> {
|
||||
tags: fixture.tags.unwrap_or_default(),
|
||||
post_type: "article".to_string(),
|
||||
image: None,
|
||||
images: Vec::new(),
|
||||
pinned: fixture.pinned.unwrap_or(false),
|
||||
published: fixture.published.unwrap_or(true),
|
||||
file_path: markdown_post_path(&fixture.slug)
|
||||
@@ -470,7 +552,11 @@ async fn canonicalize_tags(ctx: &AppContext, raw_tags: &[String]) -> Result<Vec<
|
||||
}
|
||||
|
||||
fn write_markdown_post_to_disk(post: &MarkdownPost) -> Result<()> {
|
||||
fs::write(markdown_post_path(&post.slug), build_markdown_document(post)).map_err(io_error)
|
||||
fs::write(
|
||||
markdown_post_path(&post.slug),
|
||||
build_markdown_document(post),
|
||||
)
|
||||
.map_err(io_error)
|
||||
}
|
||||
|
||||
pub fn rewrite_category_references(
|
||||
@@ -701,6 +787,17 @@ pub async fn sync_markdown_posts(ctx: &AppContext) -> Result<Vec<MarkdownPost>>
|
||||
});
|
||||
model.post_type = Set(Some(post.post_type.clone()));
|
||||
model.image = Set(post.image.clone());
|
||||
model.images = Set(if post.images.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(Value::Array(
|
||||
post.images
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(Value::String)
|
||||
.collect::<Vec<_>>(),
|
||||
))
|
||||
});
|
||||
model.pinned = Set(Some(post.pinned));
|
||||
|
||||
if has_existing {
|
||||
@@ -796,6 +893,7 @@ pub async fn create_markdown_post(
|
||||
}
|
||||
},
|
||||
image: trim_to_option(draft.image),
|
||||
images: normalize_string_list(Some(draft.images)),
|
||||
pinned: draft.pinned,
|
||||
published: draft.published,
|
||||
file_path: markdown_post_path(&slug).to_string_lossy().to_string(),
|
||||
|
||||
Reference in New Issue
Block a user