update
This commit is contained in:
34
src/ai/comment.rs
Normal file
34
src/ai/comment.rs
Normal 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
63
src/ai/editor.rs
Normal 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
87
src/ai/gpt_client.rs
Normal 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
79
src/ai/mod.rs
Normal 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
33
src/ai/translator.rs
Normal 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
108
src/atproto/client.rs
Normal 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
120
src/atproto/comment_sync.rs
Normal 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
7
src/atproto/mod.rs
Normal 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
162
src/atproto/oauth.rs
Normal 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(¶ms)
|
||||
.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(¶ms)
|
||||
.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)
|
||||
}
|
@ -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,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
121
src/generator.rs
121
src/generator.rs
@ -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,
|
||||
}
|
17
src/main.rs
17
src/main.rs
@ -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
6
src/mcp/mod.rs
Normal 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
148
src/mcp/server.rs
Normal 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
299
src/mcp/tools.rs
Normal 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
79
src/mcp/types.rs
Normal 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>,
|
||||
}
|
Reference in New Issue
Block a user