763 lines
29 KiB
Rust
763 lines
29 KiB
Rust
#![allow(clippy::missing_errors_doc)]
|
||
#![allow(clippy::unnecessary_struct_initialization)]
|
||
#![allow(clippy::unused_async)]
|
||
|
||
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,
|
||
models::_entities::{
|
||
categories, friend_links, posts, site_settings::{self, ActiveModel, Entity, Model}, tags,
|
||
},
|
||
services::{ai, content},
|
||
};
|
||
|
||
#[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>,
|
||
#[serde(default, alias = "imageModel")]
|
||
pub image_model: Option<String>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||
pub struct SiteSettingsPayload {
|
||
#[serde(default, alias = "siteName")]
|
||
pub site_name: Option<String>,
|
||
#[serde(default, alias = "siteShortName")]
|
||
pub site_short_name: Option<String>,
|
||
#[serde(default, alias = "siteUrl")]
|
||
pub site_url: Option<String>,
|
||
#[serde(default, alias = "siteTitle")]
|
||
pub site_title: Option<String>,
|
||
#[serde(default, alias = "siteDescription")]
|
||
pub site_description: Option<String>,
|
||
#[serde(default, alias = "heroTitle")]
|
||
pub hero_title: Option<String>,
|
||
#[serde(default, alias = "heroSubtitle")]
|
||
pub hero_subtitle: Option<String>,
|
||
#[serde(default, alias = "ownerName")]
|
||
pub owner_name: Option<String>,
|
||
#[serde(default, alias = "ownerTitle")]
|
||
pub owner_title: Option<String>,
|
||
#[serde(default, alias = "ownerBio")]
|
||
pub owner_bio: Option<String>,
|
||
#[serde(default, alias = "ownerAvatarUrl")]
|
||
pub owner_avatar_url: Option<String>,
|
||
#[serde(default, alias = "socialGithub")]
|
||
pub social_github: Option<String>,
|
||
#[serde(default, alias = "socialTwitter")]
|
||
pub social_twitter: Option<String>,
|
||
#[serde(default, alias = "socialEmail")]
|
||
pub social_email: Option<String>,
|
||
#[serde(default)]
|
||
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")]
|
||
pub ai_api_base: Option<String>,
|
||
#[serde(default, alias = "aiApiKey")]
|
||
pub ai_api_key: Option<String>,
|
||
#[serde(default, alias = "aiChatModel")]
|
||
pub ai_chat_model: Option<String>,
|
||
#[serde(default, alias = "aiImageProvider")]
|
||
pub ai_image_provider: Option<String>,
|
||
#[serde(default, alias = "aiImageApiBase")]
|
||
pub ai_image_api_base: Option<String>,
|
||
#[serde(default, alias = "aiImageApiKey")]
|
||
pub ai_image_api_key: Option<String>,
|
||
#[serde(default, alias = "aiImageModel")]
|
||
pub ai_image_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")]
|
||
pub ai_system_prompt: Option<String>,
|
||
#[serde(default, alias = "aiTopK")]
|
||
pub ai_top_k: Option<i32>,
|
||
#[serde(default, alias = "aiChunkSize")]
|
||
pub ai_chunk_size: Option<i32>,
|
||
#[serde(default, alias = "mediaR2AccountId")]
|
||
pub media_r2_account_id: Option<String>,
|
||
#[serde(default, alias = "mediaStorageProvider")]
|
||
pub media_storage_provider: Option<String>,
|
||
#[serde(default, alias = "mediaR2Bucket")]
|
||
pub media_r2_bucket: Option<String>,
|
||
#[serde(default, alias = "mediaR2PublicBaseUrl")]
|
||
pub media_r2_public_base_url: Option<String>,
|
||
#[serde(default, alias = "mediaR2AccessKeyId")]
|
||
pub media_r2_access_key_id: Option<String>,
|
||
#[serde(default, alias = "mediaR2SecretAccessKey")]
|
||
pub media_r2_secret_access_key: Option<String>,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize)]
|
||
pub struct PublicSiteSettingsResponse {
|
||
pub id: i32,
|
||
pub site_name: Option<String>,
|
||
pub site_short_name: Option<String>,
|
||
pub site_url: Option<String>,
|
||
pub site_title: Option<String>,
|
||
pub site_description: Option<String>,
|
||
pub hero_title: Option<String>,
|
||
pub hero_subtitle: Option<String>,
|
||
pub owner_name: Option<String>,
|
||
pub owner_title: Option<String>,
|
||
pub owner_bio: Option<String>,
|
||
pub owner_avatar_url: Option<String>,
|
||
pub social_github: Option<String>,
|
||
pub social_twitter: Option<String>,
|
||
pub social_email: Option<String>,
|
||
pub location: Option<String>,
|
||
pub tech_stack: Option<serde_json::Value>,
|
||
pub music_playlist: Option<serde_json::Value>,
|
||
pub ai_enabled: bool,
|
||
pub paragraph_comments_enabled: bool,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize)]
|
||
pub struct HomeCategorySummary {
|
||
pub id: i32,
|
||
pub name: String,
|
||
pub slug: String,
|
||
pub count: usize,
|
||
}
|
||
|
||
#[derive(Clone, Debug, Serialize)]
|
||
pub struct HomePageResponse {
|
||
pub site_settings: PublicSiteSettingsResponse,
|
||
pub posts: Vec<posts::Model>,
|
||
pub tags: Vec<tags::Model>,
|
||
pub friend_links: Vec<friend_links::Model>,
|
||
pub categories: Vec<HomeCategorySummary>,
|
||
}
|
||
|
||
fn normalize_optional_string(value: Option<String>) -> Option<String> {
|
||
value.and_then(|item| {
|
||
let trimmed = item.trim().to_string();
|
||
if trimmed.is_empty() {
|
||
None
|
||
} else {
|
||
Some(trimmed)
|
||
}
|
||
})
|
||
}
|
||
|
||
fn 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 {
|
||
let provider = ai::provider_name(None);
|
||
|
||
AiProviderConfig {
|
||
id: "default".to_string(),
|
||
name: "默认提供商".to_string(),
|
||
provider: provider.clone(),
|
||
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()),
|
||
image_model: Some(ai::default_image_model_for_provider(&provider).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 image_model = normalize_optional_string(item.image_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()
|
||
|| image_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,
|
||
image_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;
|
||
}
|
||
|
||
let normalized_provider = provider.unwrap_or_else(|| ai::provider_name(None));
|
||
|
||
Some(AiProviderConfig {
|
||
id: "default".to_string(),
|
||
name: "当前提供商".to_string(),
|
||
provider: normalized_provider.clone(),
|
||
api_base,
|
||
api_key,
|
||
chat_model,
|
||
image_model: Some(ai::default_image_model_for_provider(&normalized_provider).to_string()),
|
||
})
|
||
}
|
||
|
||
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;
|
||
config.image_model =
|
||
Some(ai::default_image_model_for_provider(&config.provider).to_string());
|
||
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();
|
||
if config.image_model.is_none() {
|
||
config.image_model =
|
||
Some(ai::default_image_model_for_provider(&config.provider).to_string());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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 Model) {
|
||
if let Some(site_name) = self.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 = normalize_optional_string(Some(site_short_name));
|
||
}
|
||
if let Some(site_url) = self.site_url {
|
||
item.site_url = normalize_optional_string(Some(site_url));
|
||
}
|
||
if let Some(site_title) = self.site_title {
|
||
item.site_title = normalize_optional_string(Some(site_title));
|
||
}
|
||
if let Some(site_description) = self.site_description {
|
||
item.site_description = normalize_optional_string(Some(site_description));
|
||
}
|
||
if let Some(hero_title) = self.hero_title {
|
||
item.hero_title = normalize_optional_string(Some(hero_title));
|
||
}
|
||
if let Some(hero_subtitle) = self.hero_subtitle {
|
||
item.hero_subtitle = normalize_optional_string(Some(hero_subtitle));
|
||
}
|
||
if let Some(owner_name) = self.owner_name {
|
||
item.owner_name = normalize_optional_string(Some(owner_name));
|
||
}
|
||
if let Some(owner_title) = self.owner_title {
|
||
item.owner_title = normalize_optional_string(Some(owner_title));
|
||
}
|
||
if let Some(owner_bio) = self.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 = normalize_optional_string(Some(owner_avatar_url));
|
||
}
|
||
if let Some(social_github) = self.social_github {
|
||
item.social_github = normalize_optional_string(Some(social_github));
|
||
}
|
||
if let Some(social_twitter) = self.social_twitter {
|
||
item.social_twitter = normalize_optional_string(Some(social_twitter));
|
||
}
|
||
if let Some(social_email) = self.social_email {
|
||
item.social_email = normalize_optional_string(Some(social_email));
|
||
}
|
||
if let Some(location) = self.location {
|
||
item.location = normalize_optional_string(Some(location));
|
||
}
|
||
if let Some(tech_stack) = self.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 = 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 = normalize_optional_string(Some(ai_provider));
|
||
}
|
||
if let Some(ai_api_base) = self.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 = normalize_optional_string(Some(ai_api_key));
|
||
}
|
||
if let Some(ai_chat_model) = self.ai_chat_model {
|
||
item.ai_chat_model = normalize_optional_string(Some(ai_chat_model));
|
||
}
|
||
if let Some(ai_image_provider) = self.ai_image_provider {
|
||
item.ai_image_provider = normalize_optional_string(Some(ai_image_provider));
|
||
}
|
||
if let Some(ai_image_api_base) = self.ai_image_api_base {
|
||
item.ai_image_api_base = normalize_optional_string(Some(ai_image_api_base));
|
||
}
|
||
if let Some(ai_image_api_key) = self.ai_image_api_key {
|
||
item.ai_image_api_key = normalize_optional_string(Some(ai_image_api_key));
|
||
}
|
||
if let Some(ai_image_model) = self.ai_image_model {
|
||
item.ai_image_model = normalize_optional_string(Some(ai_image_model));
|
||
}
|
||
if let Some(ai_embedding_model) = self.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 = normalize_optional_string(Some(ai_system_prompt));
|
||
}
|
||
if self.ai_top_k.is_some() {
|
||
item.ai_top_k = normalize_optional_int(self.ai_top_k, 1, 12);
|
||
}
|
||
if self.ai_chunk_size.is_some() {
|
||
item.ai_chunk_size = normalize_optional_int(self.ai_chunk_size, 400, 4000);
|
||
}
|
||
if let Some(media_r2_account_id) = self.media_r2_account_id {
|
||
item.media_r2_account_id = normalize_optional_string(Some(media_r2_account_id));
|
||
}
|
||
if let Some(media_storage_provider) = self.media_storage_provider {
|
||
item.media_storage_provider = normalize_optional_string(Some(media_storage_provider));
|
||
}
|
||
if let Some(media_r2_bucket) = self.media_r2_bucket {
|
||
item.media_r2_bucket = normalize_optional_string(Some(media_r2_bucket));
|
||
}
|
||
if let Some(media_r2_public_base_url) = self.media_r2_public_base_url {
|
||
item.media_r2_public_base_url =
|
||
normalize_optional_string(Some(media_r2_public_base_url));
|
||
}
|
||
if let Some(media_r2_access_key_id) = self.media_r2_access_key_id {
|
||
item.media_r2_access_key_id = normalize_optional_string(Some(media_r2_access_key_id));
|
||
}
|
||
if let Some(media_r2_secret_access_key) = self.media_r2_secret_access_key {
|
||
item.media_r2_secret_access_key =
|
||
normalize_optional_string(Some(media_r2_secret_access_key));
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn default_payload() -> SiteSettingsPayload {
|
||
SiteSettingsPayload {
|
||
site_name: Some("InitCool".to_string()),
|
||
site_short_name: Some("Termi".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("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: 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![
|
||
"Rust".to_string(),
|
||
"Go".to_string(),
|
||
"Python".to_string(),
|
||
"Svelte".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_image_provider: None,
|
||
ai_image_api_base: None,
|
||
ai_image_api_key: None,
|
||
ai_image_model: None,
|
||
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 助手。请优先基于提供的上下文回答,答案要准确、简洁、实用;如果上下文不足,请明确说明。"
|
||
.to_string(),
|
||
),
|
||
ai_top_k: Some(4),
|
||
ai_chunk_size: Some(1200),
|
||
media_storage_provider: None,
|
||
media_r2_account_id: None,
|
||
media_r2_bucket: None,
|
||
media_r2_public_base_url: None,
|
||
media_r2_access_key_id: None,
|
||
media_r2_secret_access_key: None,
|
||
}
|
||
}
|
||
|
||
pub(crate) async fn load_current(ctx: &AppContext) -> Result<Model> {
|
||
if let Some(settings) = Entity::find()
|
||
.order_by_asc(site_settings::Column::Id)
|
||
.one(&ctx.db)
|
||
.await?
|
||
{
|
||
return Ok(settings);
|
||
}
|
||
|
||
let inserted = ActiveModel {
|
||
id: Set(1),
|
||
..Default::default()
|
||
}
|
||
.insert(&ctx.db)
|
||
.await?;
|
||
let mut model = inserted;
|
||
default_payload().apply(&mut model);
|
||
Ok(model
|
||
.into_active_model()
|
||
.reset_all()
|
||
.update(&ctx.db)
|
||
.await?)
|
||
}
|
||
|
||
fn public_response(model: Model) -> PublicSiteSettingsResponse {
|
||
PublicSiteSettingsResponse {
|
||
id: model.id,
|
||
site_name: model.site_name,
|
||
site_short_name: model.site_short_name,
|
||
site_url: model.site_url,
|
||
site_title: model.site_title,
|
||
site_description: model.site_description,
|
||
hero_title: model.hero_title,
|
||
hero_subtitle: model.hero_subtitle,
|
||
owner_name: model.owner_name,
|
||
owner_title: model.owner_title,
|
||
owner_bio: model.owner_bio,
|
||
owner_avatar_url: model.owner_avatar_url,
|
||
social_github: model.social_github,
|
||
social_twitter: model.social_twitter,
|
||
social_email: model.social_email,
|
||
location: model.location,
|
||
tech_stack: model.tech_stack,
|
||
music_playlist: model.music_playlist,
|
||
ai_enabled: model.ai_enabled.unwrap_or(false),
|
||
paragraph_comments_enabled: model.paragraph_comments_enabled.unwrap_or(true),
|
||
}
|
||
}
|
||
|
||
#[debug_handler]
|
||
pub async fn home(State(ctx): State<AppContext>) -> Result<Response> {
|
||
content::sync_markdown_posts(&ctx).await?;
|
||
|
||
let site_settings = public_response(load_current(&ctx).await?);
|
||
let posts = posts::Entity::find()
|
||
.order_by_desc(posts::Column::CreatedAt)
|
||
.all(&ctx.db)
|
||
.await?;
|
||
let tags = tags::Entity::find().all(&ctx.db).await?;
|
||
let friend_links = friend_links::Entity::find()
|
||
.order_by_desc(friend_links::Column::CreatedAt)
|
||
.all(&ctx.db)
|
||
.await?;
|
||
let category_items = categories::Entity::find()
|
||
.order_by_asc(categories::Column::Slug)
|
||
.all(&ctx.db)
|
||
.await?;
|
||
|
||
let categories = category_items
|
||
.into_iter()
|
||
.map(|category| {
|
||
let name = category
|
||
.name
|
||
.clone()
|
||
.unwrap_or_else(|| category.slug.clone());
|
||
let count = posts
|
||
.iter()
|
||
.filter(|post| post.category.as_deref().map(str::trim) == Some(name.as_str()))
|
||
.count();
|
||
|
||
HomeCategorySummary {
|
||
id: category.id,
|
||
name,
|
||
slug: category.slug,
|
||
count,
|
||
}
|
||
})
|
||
.collect::<Vec<_>>();
|
||
|
||
format::json(HomePageResponse {
|
||
site_settings,
|
||
posts,
|
||
tags,
|
||
friend_links,
|
||
categories,
|
||
})
|
||
}
|
||
|
||
#[debug_handler]
|
||
pub async fn show(State(ctx): State<AppContext>) -> Result<Response> {
|
||
format::json(public_response(load_current(&ctx).await?))
|
||
}
|
||
|
||
#[debug_handler]
|
||
pub async fn update(
|
||
State(ctx): State<AppContext>,
|
||
Json(params): Json<SiteSettingsPayload>,
|
||
) -> Result<Response> {
|
||
check_auth()?;
|
||
|
||
let current = load_current(&ctx).await?;
|
||
let mut item = current;
|
||
params.apply(&mut item);
|
||
let item = item.into_active_model().reset_all();
|
||
let updated = item.update(&ctx.db).await?;
|
||
format::json(public_response(updated))
|
||
}
|
||
|
||
pub fn routes() -> Routes {
|
||
Routes::new()
|
||
.prefix("api/site_settings/")
|
||
.add("home", get(home))
|
||
.add("/", get(show))
|
||
.add("/", put(update))
|
||
.add("/", patch(update))
|
||
}
|