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:
2026-03-29 21:36:13 +08:00
parent 84f82c2a7e
commit 92a85eef20
137 changed files with 14181 additions and 2691 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(
"一名热爱技术的前端开发者,专注于构建高性能、优雅的用户界面。相信代码不仅是工具,更是一种艺术表达"
"InitCoolGitHub 用户名 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))
}

View File

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