This commit is contained in:
2025-06-06 02:14:35 +09:00
parent 02dd69840d
commit a9dca2fe38
33 changed files with 2141 additions and 9 deletions

34
src/ai/comment.rs Normal file
View File

@ -0,0 +1,34 @@
use anyhow::Result;
use serde::{Deserialize, Serialize};
use crate::ai::gpt_client::GptClient;
use crate::ai::editor::Editor;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AiComment {
pub content: String,
pub author: String,
pub timestamp: String,
}
pub struct CommentGenerator<'a> {
client: &'a GptClient,
}
impl<'a> CommentGenerator<'a> {
pub fn new(client: &'a GptClient) -> Self {
Self { client }
}
pub async fn generate_comment(&self, post_title: &str, post_content: &str) -> Result<AiComment> {
let editor = Editor::new(self.client);
let comment_content = editor.add_ai_note(post_content, post_title).await?;
let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
Ok(AiComment {
content: comment_content,
author: "AI (存在子)".to_string(),
timestamp,
})
}
}

63
src/ai/editor.rs Normal file
View File

@ -0,0 +1,63 @@
use anyhow::Result;
use crate::ai::gpt_client::GptClient;
pub struct Editor<'a> {
client: &'a GptClient,
}
impl<'a> Editor<'a> {
pub fn new(client: &'a GptClient) -> Self {
Self { client }
}
pub async fn enhance(&self, content: &str, context: &str) -> Result<String> {
let system_prompt = "You are a helpful content editor. Enhance the given content by:
1. Fixing any grammatical errors
2. Improving clarity and readability
3. Adding relevant information if needed
4. Maintaining the original tone and style
5. Preserving all Markdown formatting
Only return the enhanced content without explanations.";
let user_prompt = format!(
"Context: {}\n\nContent to enhance:\n{}",
context, content
);
self.client.chat(system_prompt, &user_prompt).await
}
pub async fn suggest_improvements(&self, content: &str) -> Result<Vec<String>> {
let system_prompt = "You are a content analyzer. Analyze the given content and provide:
1. Suggestions for improving the content
2. Missing information that could be added
3. Potential SEO improvements
Return the suggestions as a JSON array of strings.";
let response = self.client.chat(system_prompt, content).await?;
// Parse JSON response
match serde_json::from_str::<Vec<String>>(&response) {
Ok(suggestions) => Ok(suggestions),
Err(_) => {
// Fallback: split by newlines if not valid JSON
Ok(response.lines()
.filter(|s| !s.trim().is_empty())
.map(|s| s.to_string())
.collect())
}
}
}
pub async fn add_ai_note(&self, content: &str, topic: &str) -> Result<String> {
let system_prompt = format!(
"You are AI (存在子/ai). Add a brief, insightful comment about the topic '{}' \
from your unique perspective. Keep it concise (1-2 sentences) and thoughtful. \
Return only the comment text in Japanese.",
topic
);
self.client.chat(&system_prompt, content).await
}
}

87
src/ai/gpt_client.rs Normal file
View File

@ -0,0 +1,87 @@
use anyhow::Result;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::json;
#[derive(Clone)]
pub struct GptClient {
api_key: String,
endpoint: String,
client: Client,
}
#[derive(Serialize)]
struct ChatMessage {
role: String,
content: String,
}
#[derive(Deserialize)]
struct ChatResponse {
choices: Vec<Choice>,
}
#[derive(Deserialize)]
struct Choice {
message: MessageContent,
}
#[derive(Deserialize)]
struct MessageContent {
content: String,
}
impl GptClient {
pub fn new(api_key: String, endpoint: Option<String>) -> Self {
let endpoint = endpoint.unwrap_or_else(|| {
"https://api.openai.com/v1/chat/completions".to_string()
});
Self {
api_key,
endpoint,
client: Client::new(),
}
}
pub async fn chat(&self, system_prompt: &str, user_prompt: &str) -> Result<String> {
let messages = vec![
ChatMessage {
role: "system".to_string(),
content: system_prompt.to_string(),
},
ChatMessage {
role: "user".to_string(),
content: user_prompt.to_string(),
},
];
let body = json!({
"model": "gpt-4o-mini",
"messages": messages,
"temperature": 0.7,
"max_tokens": 4000,
});
let response = self.client
.post(&self.endpoint)
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json")
.json(&body)
.send()
.await?;
if !response.status().is_success() {
let error_text = response.text().await?;
anyhow::bail!("GPT API error: {}", error_text);
}
let chat_response: ChatResponse = response.json().await?;
if let Some(choice) = chat_response.choices.first() {
Ok(choice.message.content.clone())
} else {
anyhow::bail!("No response from GPT API")
}
}
}

79
src/ai/mod.rs Normal file
View File

@ -0,0 +1,79 @@
pub mod translator;
pub mod editor;
pub mod gpt_client;
pub mod comment;
pub use translator::Translator;
pub use editor::Editor;
pub use gpt_client::GptClient;
pub use comment::{AiComment, CommentGenerator};
use anyhow::Result;
use crate::config::AiConfig;
pub struct AiManager {
config: AiConfig,
gpt_client: Option<GptClient>,
}
impl AiManager {
pub fn new(config: AiConfig) -> Self {
let gpt_client = if config.enabled && config.api_key.is_some() {
Some(GptClient::new(
config.api_key.clone().unwrap(),
config.gpt_endpoint.clone(),
))
} else {
None
};
Self {
config,
gpt_client,
}
}
pub fn is_enabled(&self) -> bool {
self.config.enabled && self.gpt_client.is_some()
}
pub async fn translate(&self, content: &str, from: &str, to: &str) -> Result<String> {
if !self.is_enabled() || !self.config.auto_translate {
return Ok(content.to_string());
}
if let Some(client) = &self.gpt_client {
let translator = Translator::new(client);
translator.translate(content, from, to).await
} else {
Ok(content.to_string())
}
}
pub async fn enhance_content(&self, content: &str, context: &str) -> Result<String> {
if !self.is_enabled() {
return Ok(content.to_string());
}
if let Some(client) = &self.gpt_client {
let editor = Editor::new(client);
editor.enhance(content, context).await
} else {
Ok(content.to_string())
}
}
pub async fn generate_comment(&self, post_title: &str, post_content: &str) -> Result<Option<AiComment>> {
if !self.is_enabled() || !self.config.comment_moderation {
return Ok(None);
}
if let Some(client) = &self.gpt_client {
let generator = CommentGenerator::new(client);
let comment = generator.generate_comment(post_title, post_content).await?;
Ok(Some(comment))
} else {
Ok(None)
}
}
}

33
src/ai/translator.rs Normal file
View File

@ -0,0 +1,33 @@
use anyhow::Result;
use crate::ai::gpt_client::GptClient;
pub struct Translator<'a> {
client: &'a GptClient,
}
impl<'a> Translator<'a> {
pub fn new(client: &'a GptClient) -> Self {
Self { client }
}
pub async fn translate(&self, content: &str, from: &str, to: &str) -> Result<String> {
let system_prompt = format!(
"You are a professional translator. Translate the following text from {} to {}. \
Maintain the original formatting, including Markdown syntax. \
Only return the translated text without any explanations.",
from, to
);
self.client.chat(&system_prompt, content).await
}
pub async fn translate_post(&self, title: &str, content: &str, from: &str, to: &str) -> Result<(String, String)> {
// Translate title
let translated_title = self.translate(title, from, to).await?;
// Translate content while preserving markdown structure
let translated_content = self.translate(content, from, to).await?;
Ok((translated_title, translated_content))
}
}

108
src/atproto/client.rs Normal file
View File

@ -0,0 +1,108 @@
use anyhow::Result;
use serde::{Deserialize, Serialize};
use reqwest::header::{AUTHORIZATION, CONTENT_TYPE};
#[derive(Debug, Clone)]
pub struct AtprotoClient {
client: reqwest::Client,
handle_resolver: String,
access_token: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CreateRecordRequest {
pub repo: String,
pub collection: String,
pub record: serde_json::Value,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CreateRecordResponse {
pub uri: String,
pub cid: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CommentRecord {
#[serde(rename = "$type")]
pub record_type: String,
pub text: String,
pub createdAt: String,
pub postUri: String,
pub author: AuthorInfo,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct AuthorInfo {
pub did: String,
pub handle: String,
}
impl AtprotoClient {
pub fn new(handle_resolver: String) -> Self {
Self {
client: reqwest::Client::new(),
handle_resolver,
access_token: None,
}
}
pub fn set_access_token(&mut self, token: String) {
self.access_token = Some(token);
}
pub async fn create_comment(&self, did: &str, post_uri: &str, text: &str) -> Result<CreateRecordResponse> {
if self.access_token.is_none() {
anyhow::bail!("Not authenticated");
}
let record = CommentRecord {
record_type: "app.bsky.feed.post".to_string(),
text: text.to_string(),
createdAt: chrono::Utc::now().to_rfc3339(),
postUri: post_uri.to_string(),
author: AuthorInfo {
did: did.to_string(),
handle: "".to_string(), // Will be resolved by the server
},
};
let request = CreateRecordRequest {
repo: did.to_string(),
collection: "app.bsky.feed.post".to_string(),
record: serde_json::to_value(record)?,
};
let response = self.client
.post(format!("{}/xrpc/com.atproto.repo.createRecord", self.handle_resolver))
.header(AUTHORIZATION, format!("Bearer {}", self.access_token.as_ref().unwrap()))
.header(CONTENT_TYPE, "application/json")
.json(&request)
.send()
.await?;
if response.status().is_success() {
let result: CreateRecordResponse = response.json().await?;
Ok(result)
} else {
let error_text = response.text().await?;
anyhow::bail!("Failed to create comment: {}", error_text)
}
}
pub async fn get_profile(&self, did: &str) -> Result<serde_json::Value> {
let response = self.client
.get(format!("{}/xrpc/app.bsky.actor.getProfile", self.handle_resolver))
.query(&[("actor", did)])
.header(AUTHORIZATION, format!("Bearer {}", self.access_token.as_ref().unwrap_or(&String::new())))
.send()
.await?;
if response.status().is_success() {
let profile = response.json().await?;
Ok(profile)
} else {
anyhow::bail!("Failed to get profile")
}
}
}

120
src/atproto/comment_sync.rs Normal file
View File

@ -0,0 +1,120 @@
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::fs;
use crate::atproto::client::AtprotoClient;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Comment {
pub id: String,
pub author: String,
pub author_did: String,
pub content: String,
pub timestamp: String,
pub post_slug: String,
pub atproto_uri: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommentStorage {
pub comments: Vec<Comment>,
}
pub struct CommentSync {
client: AtprotoClient,
storage_path: PathBuf,
}
impl CommentSync {
pub fn new(client: AtprotoClient, base_path: PathBuf) -> Self {
let storage_path = base_path.join("data/comments.json");
Self {
client,
storage_path,
}
}
pub async fn load_comments(&self) -> Result<CommentStorage> {
if self.storage_path.exists() {
let content = fs::read_to_string(&self.storage_path)?;
let storage: CommentStorage = serde_json::from_str(&content)?;
Ok(storage)
} else {
Ok(CommentStorage { comments: vec![] })
}
}
pub async fn save_comments(&self, storage: &CommentStorage) -> Result<()> {
if let Some(parent) = self.storage_path.parent() {
fs::create_dir_all(parent)?;
}
let content = serde_json::to_string_pretty(storage)?;
fs::write(&self.storage_path, content)?;
Ok(())
}
pub async fn add_comment(&mut self, post_slug: &str, author_did: &str, content: &str) -> Result<Comment> {
// Get author profile
let profile = self.client.get_profile(author_did).await?;
let author_handle = profile["handle"].as_str().unwrap_or("unknown").to_string();
// Create comment in atproto
let post_uri = format!("ailog://post/{}", post_slug);
let result = self.client.create_comment(author_did, &post_uri, content).await?;
// Create local comment record
let comment = Comment {
id: uuid::Uuid::new_v4().to_string(),
author: author_handle,
author_did: author_did.to_string(),
content: content.to_string(),
timestamp: chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(),
post_slug: post_slug.to_string(),
atproto_uri: Some(result.uri),
};
// Save to local storage
let mut storage = self.load_comments().await?;
storage.comments.push(comment.clone());
self.save_comments(&storage).await?;
Ok(comment)
}
pub async fn get_comments_for_post(&self, post_slug: &str) -> Result<Vec<Comment>> {
let storage = self.load_comments().await?;
Ok(storage.comments
.into_iter()
.filter(|c| c.post_slug == post_slug)
.collect())
}
}
// Helper to generate comment HTML
pub fn render_comments_html(comments: &[Comment]) -> String {
let mut html = String::from("<div class=\"comments\">\n");
html.push_str(" <h3>コメント</h3>\n");
if comments.is_empty() {
html.push_str(" <p>まだコメントはありません。</p>\n");
} else {
for comment in comments {
html.push_str(&format!(
r#" <div class="comment">
<div class="comment-header">
<span class="author">@{}</span>
<span class="timestamp">{}</span>
</div>
<div class="comment-content">{}</div>
</div>
"#,
comment.author,
comment.timestamp,
comment.content
));
}
}
html.push_str("</div>");
html
}

7
src/atproto/mod.rs Normal file
View File

@ -0,0 +1,7 @@
pub mod oauth;
pub mod client;
pub mod comment_sync;
pub use oauth::OAuthHandler;
pub use client::AtprotoClient;
pub use comment_sync::CommentSync;

162
src/atproto/oauth.rs Normal file
View File

@ -0,0 +1,162 @@
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use crate::config::AtprotoConfig;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClientMetadata {
pub client_id: String,
pub client_name: String,
pub client_uri: String,
pub logo_uri: String,
pub tos_uri: String,
pub policy_uri: String,
pub redirect_uris: Vec<String>,
pub scope: String,
pub grant_types: Vec<String>,
pub response_types: Vec<String>,
pub token_endpoint_auth_method: String,
pub application_type: String,
pub dpop_bound_access_tokens: bool,
}
#[derive(Debug, Clone)]
pub struct OAuthHandler {
config: AtprotoConfig,
client: reqwest::Client,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct AuthorizationRequest {
pub response_type: String,
pub client_id: String,
pub redirect_uri: String,
pub state: String,
pub scope: String,
pub code_challenge: String,
pub code_challenge_method: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct TokenResponse {
pub access_token: String,
pub token_type: String,
pub expires_in: u64,
pub refresh_token: Option<String>,
pub scope: String,
}
impl OAuthHandler {
pub fn new(config: AtprotoConfig) -> Self {
Self {
config,
client: reqwest::Client::new(),
}
}
pub fn generate_client_metadata(&self) -> ClientMetadata {
ClientMetadata {
client_id: self.config.client_id.clone(),
client_name: "ailog - AI-powered blog".to_string(),
client_uri: "https://example.com".to_string(),
logo_uri: "https://example.com/logo.png".to_string(),
tos_uri: "https://example.com/tos".to_string(),
policy_uri: "https://example.com/policy".to_string(),
redirect_uris: vec![self.config.redirect_uri.clone()],
scope: "atproto".to_string(),
grant_types: vec!["authorization_code".to_string(), "refresh_token".to_string()],
response_types: vec!["code".to_string()],
token_endpoint_auth_method: "none".to_string(),
application_type: "web".to_string(),
dpop_bound_access_tokens: true,
}
}
pub fn generate_authorization_url(&self, state: &str, code_challenge: &str) -> String {
let params = vec![
("response_type", "code"),
("client_id", &self.config.client_id),
("redirect_uri", &self.config.redirect_uri),
("state", state),
("scope", "atproto"),
("code_challenge", code_challenge),
("code_challenge_method", "S256"),
];
let query = params.into_iter()
.map(|(k, v)| format!("{}={}", k, urlencoding::encode(v)))
.collect::<Vec<_>>()
.join("&");
format!("{}/oauth/authorize?{}", self.config.handle_resolver, query)
}
pub async fn exchange_code(&self, code: &str, code_verifier: &str) -> Result<TokenResponse> {
let params = HashMap::from([
("grant_type", "authorization_code"),
("code", code),
("redirect_uri", &self.config.redirect_uri),
("client_id", &self.config.client_id),
("code_verifier", code_verifier),
]);
let response = self.client
.post(format!("{}/oauth/token", self.config.handle_resolver))
.form(&params)
.send()
.await?;
if response.status().is_success() {
let token: TokenResponse = response.json().await?;
Ok(token)
} else {
anyhow::bail!("Failed to exchange authorization code")
}
}
pub async fn refresh_token(&self, refresh_token: &str) -> Result<TokenResponse> {
let params = HashMap::from([
("grant_type", "refresh_token"),
("refresh_token", refresh_token),
("client_id", &self.config.client_id),
]);
let response = self.client
.post(format!("{}/oauth/token", self.config.handle_resolver))
.form(&params)
.send()
.await?;
if response.status().is_success() {
let token: TokenResponse = response.json().await?;
Ok(token)
} else {
anyhow::bail!("Failed to refresh token")
}
}
}
// PKCE helpers
pub fn generate_code_verifier() -> String {
use rand::Rng;
const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
let mut rng = rand::thread_rng();
(0..128)
.map(|_| {
let idx = rng.gen_range(0..CHARSET.len());
CHARSET[idx] as char
})
.collect()
}
pub fn generate_code_challenge(verifier: &str) -> String {
use sha2::{Sha256, Digest};
use base64::{Engine as _, engine::general_purpose};
let mut hasher = Sha256::new();
hasher.update(verifier.as_bytes());
let result = hasher.finalize();
general_purpose::URL_SAFE_NO_PAD.encode(result)
}

View File

@ -1,7 +1,8 @@
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::env;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Config {
@ -29,15 +30,100 @@ pub struct AiConfig {
pub enabled: bool,
pub auto_translate: bool,
pub comment_moderation: bool,
pub api_key: Option<String>,
pub gpt_endpoint: Option<String>,
pub atproto_config: Option<AtprotoConfig>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct AtprotoConfig {
pub client_id: String,
pub redirect_uri: String,
pub handle_resolver: String,
}
impl Config {
pub fn load(path: &Path) -> Result<Self> {
let config_path = path.join("config.toml");
let content = fs::read_to_string(config_path)?;
let config: Config = toml::from_str(&content)?;
let mut config: Config = toml::from_str(&content)?;
// Load global config and merge
if let Ok(global_config) = Self::load_global_config() {
config = config.merge(global_config);
}
// Override with environment variables
config.override_from_env();
Ok(config)
}
fn load_global_config() -> Result<Config> {
let config_dir = Self::global_config_dir();
let config_path = config_dir.join("config.toml");
if config_path.exists() {
let content = fs::read_to_string(config_path)?;
let config: Config = toml::from_str(&content)?;
Ok(config)
} else {
anyhow::bail!("Global config not found")
}
}
pub fn global_config_dir() -> PathBuf {
if let Ok(home) = env::var("HOME") {
PathBuf::from(home).join(".config").join("syui").join("ai").join("log")
} else {
PathBuf::from("~/.config/syui/ai/log")
}
}
fn merge(mut self, global: Config) -> Self {
// Merge AI config
if let Some(global_ai) = global.ai {
if let Some(ref mut ai) = self.ai {
if ai.api_key.is_none() {
ai.api_key = global_ai.api_key;
}
if ai.gpt_endpoint.is_none() {
ai.gpt_endpoint = global_ai.gpt_endpoint;
}
if ai.atproto_config.is_none() {
ai.atproto_config = global_ai.atproto_config;
}
} else {
self.ai = Some(global_ai);
}
}
self
}
fn override_from_env(&mut self) {
if let Ok(api_key) = env::var("AILOG_API_KEY") {
if let Some(ref mut ai) = self.ai {
ai.api_key = Some(api_key);
}
}
if let Ok(endpoint) = env::var("AILOG_GPT_ENDPOINT") {
if let Some(ref mut ai) = self.ai {
ai.gpt_endpoint = Some(endpoint);
}
}
}
pub fn save_global(&self) -> Result<()> {
let config_dir = Self::global_config_dir();
fs::create_dir_all(&config_dir)?;
let config_path = config_dir.join("config.toml");
let content = toml::to_string_pretty(self)?;
fs::write(config_path, content)?;
Ok(())
}
}
impl Default for Config {
@ -57,6 +143,9 @@ impl Default for Config {
enabled: false,
auto_translate: false,
comment_moderation: false,
api_key: None,
gpt_endpoint: None,
atproto_config: None,
}),
}
}

View File

@ -6,24 +6,37 @@ use std::fs;
use crate::config::Config;
use crate::markdown::MarkdownProcessor;
use crate::template::TemplateEngine;
use crate::ai::AiManager;
pub struct Generator {
base_path: PathBuf,
config: Config,
markdown_processor: MarkdownProcessor,
template_engine: TemplateEngine,
ai_manager: Option<AiManager>,
}
impl Generator {
pub fn new(base_path: PathBuf, config: Config) -> Result<Self> {
let markdown_processor = MarkdownProcessor::new(config.build.highlight_code);
let template_engine = TemplateEngine::new(base_path.join("templates"))?;
let ai_manager = if let Some(ref ai_config) = config.ai {
if ai_config.enabled {
Some(AiManager::new(ai_config.clone()))
} else {
None
}
} else {
None
};
Ok(Self {
base_path,
config,
markdown_processor,
template_engine,
ai_manager,
})
}
@ -47,6 +60,13 @@ impl Generator {
// Generate post pages
for post in &posts {
self.generate_post_page(post).await?;
// Generate translation pages
if let Some(ref translations) = post.translations {
for translation in translations {
self.generate_translation_page(post, translation).await?;
}
}
}
println!("{} {} posts", "Generated".cyan(), posts.len());
@ -106,7 +126,21 @@ impl Generator {
async fn process_single_post(&self, path: &std::path::Path) -> Result<Post> {
let content = fs::read_to_string(path)?;
let (frontmatter, content) = self.markdown_processor.parse_frontmatter(&content)?;
let (frontmatter, mut content) = self.markdown_processor.parse_frontmatter(&content)?;
// Apply AI enhancements if enabled
if let Some(ref ai_manager) = self.ai_manager {
// Enhance content with AI
let title = frontmatter.get("title")
.and_then(|v| v.as_str())
.unwrap_or("Untitled");
content = ai_manager.enhance_content(&content, title).await
.unwrap_or_else(|e| {
eprintln!("AI enhancement failed: {}", e);
content
});
}
let html_content = self.markdown_processor.render(&content)?;
@ -116,7 +150,7 @@ impl Generator {
.unwrap_or("post")
.to_string();
let post = Post {
let mut post = Post {
title: frontmatter.get("title")
.and_then(|v| v.as_str())
.unwrap_or("Untitled")
@ -135,7 +169,43 @@ impl Generator {
.map(|s| s.to_string())
.collect())
.unwrap_or_default(),
translations: None,
ai_comment: None,
};
// Auto-translate if enabled and post is in Japanese
if let Some(ref ai_manager) = self.ai_manager {
if self.config.ai.as_ref().map_or(false, |ai| ai.auto_translate)
&& self.config.site.language == "ja" {
match ai_manager.translate(&content, "ja", "en").await {
Ok(translated_content) => {
let translated_html = self.markdown_processor.render(&translated_content)?;
let translated_title = ai_manager.translate(&post.title, "ja", "en").await
.unwrap_or_else(|_| post.title.clone());
post.translations = Some(vec![Translation {
lang: "en".to_string(),
title: translated_title,
content: translated_html,
url: format!("/posts/{}-en.html", post.slug),
}]);
}
Err(e) => eprintln!("Translation failed: {}", e),
}
}
// Generate AI comment
if self.config.ai.as_ref().map_or(false, |ai| ai.comment_moderation) {
match ai_manager.generate_comment(&post.title, &content).await {
Ok(Some(comment)) => {
post.ai_comment = Some(comment.content);
}
Ok(None) => {}
Err(e) => eprintln!("AI comment generation failed: {}", e),
}
}
}
Ok(post)
}
@ -165,6 +235,43 @@ impl Generator {
Ok(())
}
async fn generate_translation_page(&self, post: &Post, translation: &Translation) -> Result<()> {
let mut context = tera::Context::new();
context.insert("config", &self.config.site);
context.insert("post", &TranslatedPost {
title: translation.title.clone(),
date: post.date.clone(),
content: translation.content.clone(),
slug: post.slug.clone(),
url: translation.url.clone(),
tags: post.tags.clone(),
original_url: post.url.clone(),
lang: translation.lang.clone(),
});
let html = self.template_engine.render_with_context("post.html", &context)?;
let output_dir = self.base_path.join("public/posts");
fs::create_dir_all(&output_dir)?;
let output_path = output_dir.join(format!("{}-{}.html", post.slug, translation.lang));
fs::write(output_path, html)?;
Ok(())
}
}
#[derive(Debug, Clone, serde::Serialize)]
struct TranslatedPost {
pub title: String,
pub date: String,
pub content: String,
pub slug: String,
pub url: String,
pub tags: Vec<String>,
pub original_url: String,
pub lang: String,
}
#[derive(Debug, Clone, serde::Serialize)]
@ -175,4 +282,14 @@ pub struct Post {
pub slug: String,
pub url: String,
pub tags: Vec<String>,
pub translations: Option<Vec<Translation>>,
pub ai_comment: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Translation {
pub lang: String,
pub title: String,
pub content: String,
pub url: String,
}

View File

@ -7,6 +7,9 @@ mod generator;
mod markdown;
mod template;
mod config;
mod ai;
mod atproto;
mod mcp;
#[derive(Parser)]
#[command(name = "ailog")]
@ -47,6 +50,15 @@ enum Commands {
},
/// Clean build artifacts
Clean,
/// Start MCP server for ai.gpt integration
Mcp {
/// Port to serve MCP on
#[arg(short, long, default_value = "8002")]
port: u16,
/// Path to the blog directory
#[arg(default_value = ".")]
path: PathBuf,
},
}
#[tokio::main]
@ -69,6 +81,11 @@ async fn main() -> Result<()> {
Commands::Clean => {
commands::clean::execute().await?;
}
Commands::Mcp { port, path } => {
use crate::mcp::McpServer;
let server = McpServer::new(path);
server.serve(port).await?;
}
}
Ok(())

6
src/mcp/mod.rs Normal file
View File

@ -0,0 +1,6 @@
pub mod server;
pub mod tools;
pub mod types;
pub use server::McpServer;
pub use types::*;

148
src/mcp/server.rs Normal file
View File

@ -0,0 +1,148 @@
use anyhow::Result;
use axum::{
extract::{Query, State},
http::StatusCode,
response::Json,
routing::{get, post},
Router,
};
use serde_json::{json, Value};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use tower_http::cors::CorsLayer;
use crate::mcp::types::*;
use crate::mcp::tools::BlogTools;
#[derive(Clone)]
pub struct AppState {
blog_tools: Arc<BlogTools>,
}
pub struct McpServer {
app_state: AppState,
}
impl McpServer {
pub fn new(base_path: PathBuf) -> Self {
let blog_tools = Arc::new(BlogTools::new(base_path));
let app_state = AppState { blog_tools };
Self { app_state }
}
pub fn create_router(&self) -> Router {
Router::new()
.route("/", get(root_handler))
.route("/mcp/tools/list", get(list_tools))
.route("/mcp/tools/call", post(call_tool))
.route("/health", get(health_check))
.layer(CorsLayer::permissive())
.with_state(self.app_state.clone())
}
pub async fn serve(&self, port: u16) -> Result<()> {
let app = self.create_router();
let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", port)).await?;
println!("ai.log MCP Server listening on port {}", port);
axum::serve(listener, app).await?;
Ok(())
}
}
async fn root_handler() -> Json<Value> {
Json(json!({
"name": "ai.log MCP Server",
"version": "0.1.0",
"description": "AI-powered static blog generator with MCP integration",
"tools": ["create_blog_post", "list_blog_posts", "build_blog", "get_post_content"]
}))
}
async fn health_check() -> Json<Value> {
Json(json!({
"status": "healthy",
"timestamp": chrono::Utc::now().to_rfc3339()
}))
}
async fn list_tools() -> Json<Value> {
let tools = BlogTools::get_tools();
Json(json!({
"tools": tools
}))
}
async fn call_tool(
State(state): State<AppState>,
Json(request): Json<McpRequest>,
) -> Result<Json<McpResponse>, StatusCode> {
let tool_name = request.params
.as_ref()
.and_then(|p| p.get("name"))
.and_then(|v| v.as_str())
.ok_or(StatusCode::BAD_REQUEST)?;
let arguments = request.params
.as_ref()
.and_then(|p| p.get("arguments"))
.cloned()
.unwrap_or(json!({}));
let result = match tool_name {
"create_blog_post" => {
let req: CreatePostRequest = serde_json::from_value(arguments)
.map_err(|_| StatusCode::BAD_REQUEST)?;
state.blog_tools.create_post(req).await
}
"list_blog_posts" => {
let req: ListPostsRequest = serde_json::from_value(arguments)
.map_err(|_| StatusCode::BAD_REQUEST)?;
state.blog_tools.list_posts(req).await
}
"build_blog" => {
let req: BuildRequest = serde_json::from_value(arguments)
.map_err(|_| StatusCode::BAD_REQUEST)?;
state.blog_tools.build_blog(req).await
}
"get_post_content" => {
let slug = arguments.get("slug")
.and_then(|v| v.as_str())
.ok_or(StatusCode::BAD_REQUEST)?;
state.blog_tools.get_post_content(slug).await
}
_ => {
return Ok(Json(McpResponse {
jsonrpc: "2.0".to_string(),
id: request.id,
result: None,
error: Some(McpError {
code: -32601,
message: format!("Method not found: {}", tool_name),
data: None,
}),
}));
}
};
match result {
Ok(tool_result) => Ok(Json(McpResponse {
jsonrpc: "2.0".to_string(),
id: request.id,
result: Some(serde_json::to_value(tool_result).unwrap()),
error: None,
})),
Err(e) => Ok(Json(McpResponse {
jsonrpc: "2.0".to_string(),
id: request.id,
result: None,
error: Some(McpError {
code: -32000,
message: e.to_string(),
data: None,
}),
})),
}
}

299
src/mcp/tools.rs Normal file
View File

@ -0,0 +1,299 @@
use anyhow::Result;
use serde_json::{json, Value};
use std::path::PathBuf;
use std::fs;
use chrono::Local;
use crate::mcp::types::*;
use crate::generator::Generator;
use crate::config::Config;
pub struct BlogTools {
base_path: PathBuf,
}
impl BlogTools {
pub fn new(base_path: PathBuf) -> Self {
Self { base_path }
}
pub async fn create_post(&self, request: CreatePostRequest) -> Result<ToolResult> {
let posts_dir = self.base_path.join("content/posts");
// Generate slug if not provided
let slug = request.slug.unwrap_or_else(|| {
request.title
.chars()
.map(|c| if c.is_alphanumeric() || c == ' ' { c.to_lowercase().to_string() } else { "".to_string() })
.collect::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.join("-")
});
let date = Local::now().format("%Y-%m-%d").to_string();
let filename = format!("{}-{}.md", date, slug);
let filepath = posts_dir.join(&filename);
// Create frontmatter
let mut frontmatter = format!(
"---\ntitle: {}\ndate: {}\n",
request.title, date
);
if let Some(tags) = request.tags {
if !tags.is_empty() {
frontmatter.push_str(&format!("tags: {:?}\n", tags));
}
}
frontmatter.push_str("---\n\n");
// Create full content
let full_content = format!("{}{}", frontmatter, request.content);
// Ensure directory exists
fs::create_dir_all(&posts_dir)?;
// Write file
fs::write(&filepath, full_content)?;
Ok(ToolResult {
content: vec![Content {
content_type: "text".to_string(),
text: format!("Post created successfully: {}", filename),
}],
is_error: None,
})
}
pub async fn list_posts(&self, request: ListPostsRequest) -> Result<ToolResult> {
let posts_dir = self.base_path.join("content/posts");
if !posts_dir.exists() {
return Ok(ToolResult {
content: vec![Content {
content_type: "text".to_string(),
text: "No posts directory found".to_string(),
}],
is_error: Some(true),
});
}
let mut posts = Vec::new();
for entry in fs::read_dir(&posts_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_file() && path.extension().map_or(false, |ext| ext == "md") {
if let Ok(content) = fs::read_to_string(&path) {
// Parse frontmatter
if let Some((frontmatter_str, _)) = content.split_once("---\n") {
if let Some((_, frontmatter_content)) = frontmatter_str.split_once("---\n") {
// Simple YAML parsing for basic fields
let mut title = "Untitled".to_string();
let mut date = "Unknown".to_string();
let mut tags = Vec::new();
for line in frontmatter_content.lines() {
if let Some((key, value)) = line.split_once(':') {
let key = key.trim();
let value = value.trim();
match key {
"title" => title = value.to_string(),
"date" => date = value.to_string(),
"tags" => {
// Simple array parsing
if value.starts_with('[') && value.ends_with(']') {
let tags_str = &value[1..value.len()-1];
tags = tags_str.split(',')
.map(|s| s.trim().trim_matches('"').to_string())
.collect();
}
}
_ => {}
}
}
}
let slug = path.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
posts.push(PostInfo {
title,
slug: slug.clone(),
date,
tags,
url: format!("/posts/{}.html", slug),
});
}
}
}
}
}
// Apply pagination
let offset = request.offset.unwrap_or(0);
let limit = request.limit.unwrap_or(10);
posts.sort_by(|a, b| b.date.cmp(&a.date));
let paginated_posts: Vec<_> = posts.into_iter()
.skip(offset)
.take(limit)
.collect();
let result = json!({
"posts": paginated_posts,
"total": paginated_posts.len()
});
Ok(ToolResult {
content: vec![Content {
content_type: "text".to_string(),
text: serde_json::to_string_pretty(&result)?,
}],
is_error: None,
})
}
pub async fn build_blog(&self, request: BuildRequest) -> Result<ToolResult> {
// Load configuration
let config = Config::load(&self.base_path)?;
// Create generator
let generator = Generator::new(self.base_path.clone(), config)?;
// Build the blog
generator.build().await?;
let message = if request.enable_ai.unwrap_or(false) {
"Blog built successfully with AI features enabled"
} else {
"Blog built successfully"
};
Ok(ToolResult {
content: vec![Content {
content_type: "text".to_string(),
text: message.to_string(),
}],
is_error: None,
})
}
pub async fn get_post_content(&self, slug: &str) -> Result<ToolResult> {
let posts_dir = self.base_path.join("content/posts");
// Find file by slug
for entry in fs::read_dir(&posts_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_file() && path.extension().map_or(false, |ext| ext == "md") {
if let Some(filename) = path.file_stem().and_then(|s| s.to_str()) {
if filename.contains(slug) {
let content = fs::read_to_string(&path)?;
return Ok(ToolResult {
content: vec![Content {
content_type: "text".to_string(),
text: content,
}],
is_error: None,
});
}
}
}
}
Ok(ToolResult {
content: vec![Content {
content_type: "text".to_string(),
text: format!("Post with slug '{}' not found", slug),
}],
is_error: Some(true),
})
}
pub fn get_tools() -> Vec<Tool> {
vec![
Tool {
name: "create_blog_post".to_string(),
description: "Create a new blog post with title, content, and optional tags".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "The title of the blog post"
},
"content": {
"type": "string",
"description": "The content of the blog post in Markdown format"
},
"tags": {
"type": "array",
"items": {"type": "string"},
"description": "Optional tags for the blog post"
},
"slug": {
"type": "string",
"description": "Optional custom slug for the post URL"
}
},
"required": ["title", "content"]
}),
},
Tool {
name: "list_blog_posts".to_string(),
description: "List existing blog posts with pagination".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"limit": {
"type": "integer",
"description": "Maximum number of posts to return (default: 10)"
},
"offset": {
"type": "integer",
"description": "Number of posts to skip (default: 0)"
}
}
}),
},
Tool {
name: "build_blog".to_string(),
description: "Build the static blog with AI features".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"enable_ai": {
"type": "boolean",
"description": "Enable AI features during build (default: false)"
},
"translate": {
"type": "boolean",
"description": "Enable automatic translation (default: false)"
}
}
}),
},
Tool {
name: "get_post_content".to_string(),
description: "Get the full content of a blog post by slug".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"slug": {
"type": "string",
"description": "The slug of the blog post to retrieve"
}
},
"required": ["slug"]
}),
},
]
}
}

79
src/mcp/types.rs Normal file
View File

@ -0,0 +1,79 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpRequest {
pub jsonrpc: String,
pub id: Option<serde_json::Value>,
pub method: String,
pub params: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpResponse {
pub jsonrpc: String,
pub id: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<McpError>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpError {
pub code: i32,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Tool {
pub name: String,
pub description: String,
#[serde(rename = "inputSchema")]
pub input_schema: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolResult {
pub content: Vec<Content>,
#[serde(rename = "isError", skip_serializing_if = "Option::is_none")]
pub is_error: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Content {
#[serde(rename = "type")]
pub content_type: String,
pub text: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreatePostRequest {
pub title: String,
pub content: String,
pub tags: Option<Vec<String>>,
pub slug: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListPostsRequest {
pub limit: Option<usize>,
pub offset: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PostInfo {
pub title: String,
pub slug: String,
pub date: String,
pub tags: Vec<String>,
pub url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BuildRequest {
pub enable_ai: Option<bool>,
pub translate: Option<bool>,
}