From a9dca2fe38f5f975dd6d4089eda1d61a55f4ea0c Mon Sep 17 00:00:00 2001 From: syui Date: Fri, 6 Jun 2025 02:14:35 +0900 Subject: [PATCH] update --- .claude/settings.local.json | 10 +- Cargo.toml | 9 + README.md | 22 +- mcp_integration.md | 142 +++++++++ src/ai/comment.rs | 34 ++ src/ai/editor.rs | 63 ++++ src/ai/gpt_client.rs | 87 +++++ src/ai/mod.rs | 79 +++++ src/ai/translator.rs | 33 ++ src/atproto/client.rs | 108 +++++++ src/atproto/comment_sync.rs | 120 +++++++ src/atproto/mod.rs | 7 + src/atproto/oauth.rs | 162 ++++++++++ src/config.rs | 93 +++++- src/generator.rs | 121 ++++++- src/main.rs | 17 + src/mcp/mod.rs | 6 + src/mcp/server.rs | 148 +++++++++ src/mcp/tools.rs | 299 ++++++++++++++++++ src/mcp/types.rs | 79 +++++ test-blog/config.toml | 21 ++ .../2025-06-06-ai統合ブログシステムの紹介.md | 39 +++ test-blog/content/posts/welcome.md | 32 ++ test-blog/public/css/style.css | 58 ++++ test-blog/public/index.html | 38 +++ ...5-06-06-ai統合ブログシステムの紹介-en.html | 60 ++++ ...2025-06-06-ai統合ブログシステムの紹介.html | 60 ++++ test-blog/public/posts/welcome-en.html | 48 +++ test-blog/public/posts/welcome.html | 48 +++ test-blog/static/css/style.css | 58 ++++ test-blog/templates/base.html | 23 ++ test-blog/templates/index.html | 13 + test-blog/templates/post.html | 13 + 33 files changed, 2141 insertions(+), 9 deletions(-) create mode 100644 mcp_integration.md create mode 100644 src/ai/comment.rs create mode 100644 src/ai/editor.rs create mode 100644 src/ai/gpt_client.rs create mode 100644 src/ai/mod.rs create mode 100644 src/ai/translator.rs create mode 100644 src/atproto/client.rs create mode 100644 src/atproto/comment_sync.rs create mode 100644 src/atproto/mod.rs create mode 100644 src/atproto/oauth.rs create mode 100644 src/mcp/mod.rs create mode 100644 src/mcp/server.rs create mode 100644 src/mcp/tools.rs create mode 100644 src/mcp/types.rs create mode 100644 test-blog/config.toml create mode 100644 test-blog/content/posts/2025-06-06-ai統合ブログシステムの紹介.md create mode 100644 test-blog/content/posts/welcome.md create mode 100644 test-blog/public/css/style.css create mode 100644 test-blog/public/index.html create mode 100644 test-blog/public/posts/2025-06-06-ai統合ブログシステムの紹介-en.html create mode 100644 test-blog/public/posts/2025-06-06-ai統合ブログシステムの紹介.html create mode 100644 test-blog/public/posts/welcome-en.html create mode 100644 test-blog/public/posts/welcome.html create mode 100644 test-blog/static/css/style.css create mode 100644 test-blog/templates/base.html create mode 100644 test-blog/templates/index.html create mode 100644 test-blog/templates/post.html diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 43dced9..493412e 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -2,7 +2,15 @@ "permissions": { "allow": [ "Bash(cargo init:*)", - "Bash(cargo:*)" + "Bash(cargo:*)", + "Bash(find:*)", + "Bash(mkdir:*)", + "Bash(../target/debug/ailog new:*)", + "Bash(../target/debug/ailog build)", + "Bash(/Users/syui/ai/log/target/debug/ailog build)", + "Bash(ls:*)", + "Bash(curl:*)", + "Bash(pkill:*)" ], "deny": [] } diff --git a/Cargo.toml b/Cargo.toml index 5edcc5e..ace735b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,15 @@ colored = "2.1" serde_yaml = "0.9" syntect = "5.2" reqwest = { version = "0.12", features = ["json"] } +rand = "0.8" +sha2 = "0.10" +base64 = "0.22" +uuid = { version = "1.11", features = ["v4"] } +urlencoding = "2.1" +axum = "0.7" +tower = "0.5" +tower-http = { version = "0.5", features = ["cors", "fs"] } +hyper = { version = "1.0", features = ["full"] } [dev-dependencies] tempfile = "3.14" \ No newline at end of file diff --git a/README.md b/README.md index 7305d65..c6247df 100644 --- a/README.md +++ b/README.md @@ -63,10 +63,24 @@ Designed for GitHub Actions and Cloudflare Pages deployment. Push to main branch ## Development Status Currently implemented: -- Basic static site generation -- Markdown parsing and HTML generation -- Template system -- Development server +- ✅ Project structure and Cargo.toml setup +- ✅ Basic command-line interface (init, new, build, serve, clean) +- ✅ Configuration system with TOML support +- ✅ Markdown parsing with frontmatter support +- ✅ Template system with Handlebars +- ✅ Static site generation with posts and pages +- ✅ Development server with hot reload +- ✅ AI integration foundation (GPT client, translator, comment system) +- ✅ atproto client with OAuth support +- ✅ MCP server integration for AI tools +- ✅ Test blog with sample content and styling + +Planned features: +- AI-powered content enhancement and suggestions +- Automatic translation (ja → en) pipeline +- atproto comment system with OAuth authentication +- Advanced template customization +- Plugin system for extensibility ## License diff --git a/mcp_integration.md b/mcp_integration.md new file mode 100644 index 0000000..398c43c --- /dev/null +++ b/mcp_integration.md @@ -0,0 +1,142 @@ +# ai.log MCP Integration Guide + +ai.logをai.gptと連携するためのMCPサーバー設定ガイド + +## MCPサーバー起動 + +```bash +# ai.logプロジェクトディレクトリで +./target/debug/ailog mcp --port 8002 + +# またはサブディレクトリから +./target/debug/ailog mcp --port 8002 --path /path/to/blog +``` + +## ai.gptでの設定 + +ai.gptの設定ファイル `~/.config/syui/ai/gpt/config.json` に以下を追加: + +```json +{ + "mcp": { + "enabled": true, + "servers": { + "ai_gpt": {"base_url": "http://localhost:8001"}, + "ai_card": {"base_url": "http://localhost:8000"}, + "ai_log": {"base_url": "http://localhost:8002"} + } + } +} +``` + +## 利用可能なMCPツール + +### 1. create_blog_post +新しいブログ記事を作成します。 + +**パラメータ**: +- `title` (必須): 記事のタイトル +- `content` (必須): Markdown形式の記事内容 +- `tags` (オプション): 記事のタグ配列 +- `slug` (オプション): カスタムURL slug + +**使用例**: +```python +# ai.gptからの呼び出し例 +result = await mcp_client.call_tool("create_blog_post", { + "title": "AI統合の新しい可能性", + "content": "# 概要\n\nai.gptとai.logの連携により...", + "tags": ["AI", "技術", "ブログ"] +}) +``` + +### 2. list_blog_posts +既存のブログ記事一覧を取得します。 + +**パラメータ**: +- `limit` (オプション): 取得件数上限 (デフォルト: 10) +- `offset` (オプション): スキップ件数 (デフォルト: 0) + +### 3. build_blog +ブログをビルドして静的ファイルを生成します。 + +**パラメータ**: +- `enable_ai` (オプション): AI機能を有効化 +- `translate` (オプション): 自動翻訳を有効化 + +### 4. get_post_content +指定したスラッグの記事内容を取得します。 + +**パラメータ**: +- `slug` (必須): 記事のスラッグ + +## ai.gptからの連携パターン + +### 記事の自動投稿 +```python +# 記憶システムから関連情報を取得 +memories = get_contextual_memories("ブログ") + +# AI記事生成 +content = generate_blog_content(memories) + +# ai.logに投稿 +result = await mcp_client.call_tool("create_blog_post", { + "title": "今日の思考メモ", + "content": content, + "tags": ["日記", "AI"] +}) + +# ビルド実行 +await mcp_client.call_tool("build_blog", {"enable_ai": True}) +``` + +### 記事一覧の確認と編集 +```python +# 記事一覧取得 +posts = await mcp_client.call_tool("list_blog_posts", {"limit": 5}) + +# 特定記事の内容取得 +content = await mcp_client.call_tool("get_post_content", { + "slug": "ai-integration" +}) + +# 修正版を投稿(上書き) +updated_content = enhance_content(content) +await mcp_client.call_tool("create_blog_post", { + "title": "AI統合の新しい可能性(改訂版)", + "content": updated_content, + "slug": "ai-integration-revised" +}) +``` + +## 自動化ワークフロー + +ai.gptのスケジューラーと組み合わせて: + +1. **日次ブログ投稿**: 蓄積された記憶から記事を自動生成・投稿 +2. **記事修正**: 既存記事の内容を自動改善 +3. **関連記事提案**: 過去記事との関連性に基づく新記事提案 +4. **多言語対応**: 自動翻訳によるグローバル展開 + +## エラーハンドリング + +MCPツール呼び出し時のエラーは以下の形式で返されます: + +```json +{ + "jsonrpc": "2.0", + "id": "request_id", + "error": { + "code": -32000, + "message": "エラーメッセージ", + "data": null + } +} +``` + +## セキュリティ考慮事項 + +- MCPサーバーはローカルホストでのみ動作 +- ai.gptからの認証済みリクエストのみ処理 +- ファイルアクセスは指定されたブログディレクトリ内に制限 \ No newline at end of file diff --git a/src/ai/comment.rs b/src/ai/comment.rs new file mode 100644 index 0000000..971a5f5 --- /dev/null +++ b/src/ai/comment.rs @@ -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 { + 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, + }) + } +} \ No newline at end of file diff --git a/src/ai/editor.rs b/src/ai/editor.rs new file mode 100644 index 0000000..7ac320d --- /dev/null +++ b/src/ai/editor.rs @@ -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 { + 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> { + 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::>(&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 { + 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 + } +} \ No newline at end of file diff --git a/src/ai/gpt_client.rs b/src/ai/gpt_client.rs new file mode 100644 index 0000000..0bc4c60 --- /dev/null +++ b/src/ai/gpt_client.rs @@ -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, +} + +#[derive(Deserialize)] +struct Choice { + message: MessageContent, +} + +#[derive(Deserialize)] +struct MessageContent { + content: String, +} + +impl GptClient { + pub fn new(api_key: String, endpoint: Option) -> 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 { + 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") + } + } +} \ No newline at end of file diff --git a/src/ai/mod.rs b/src/ai/mod.rs new file mode 100644 index 0000000..cb0a14c --- /dev/null +++ b/src/ai/mod.rs @@ -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, +} + +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 { + 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 { + 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> { + 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) + } + } +} \ No newline at end of file diff --git a/src/ai/translator.rs b/src/ai/translator.rs new file mode 100644 index 0000000..2073aa9 --- /dev/null +++ b/src/ai/translator.rs @@ -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 { + 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)) + } +} \ No newline at end of file diff --git a/src/atproto/client.rs b/src/atproto/client.rs new file mode 100644 index 0000000..60a8ead --- /dev/null +++ b/src/atproto/client.rs @@ -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, +} + +#[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 { + 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 { + 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") + } + } +} \ No newline at end of file diff --git a/src/atproto/comment_sync.rs b/src/atproto/comment_sync.rs new file mode 100644 index 0000000..cdcfc35 --- /dev/null +++ b/src/atproto/comment_sync.rs @@ -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, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CommentStorage { + pub comments: Vec, +} + +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 { + 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 { + // 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> { + 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("
\n"); + html.push_str("

コメント

\n"); + + if comments.is_empty() { + html.push_str("

まだコメントはありません。

\n"); + } else { + for comment in comments { + html.push_str(&format!( + r#"
+
+ @{} + {} +
+
{}
+
+"#, + comment.author, + comment.timestamp, + comment.content + )); + } + } + + html.push_str("
"); + html +} \ No newline at end of file diff --git a/src/atproto/mod.rs b/src/atproto/mod.rs new file mode 100644 index 0000000..186bf89 --- /dev/null +++ b/src/atproto/mod.rs @@ -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; \ No newline at end of file diff --git a/src/atproto/oauth.rs b/src/atproto/oauth.rs new file mode 100644 index 0000000..d3c4bb0 --- /dev/null +++ b/src/atproto/oauth.rs @@ -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, + pub scope: String, + pub grant_types: Vec, + pub response_types: Vec, + 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, + 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::>() + .join("&"); + + format!("{}/oauth/authorize?{}", self.config.handle_resolver, query) + } + + pub async fn exchange_code(&self, code: &str, code_verifier: &str) -> Result { + 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 { + 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) +} \ No newline at end of file diff --git a/src/config.rs b/src/config.rs index 32ecf0b..3122af6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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, + pub gpt_endpoint: Option, + pub atproto_config: Option, +} + +#[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 { 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 { + 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, }), } } diff --git a/src/generator.rs b/src/generator.rs index 7d95e85..329d5eb 100644 --- a/src/generator.rs +++ b/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, } impl Generator { pub fn new(base_path: PathBuf, config: Config) -> Result { 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 { 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, + 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, + pub translations: Option>, + pub ai_comment: Option, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct Translation { + pub lang: String, + pub title: String, + pub content: String, + pub url: String, } \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 32d5c1c..bba7f14 100644 --- a/src/main.rs +++ b/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(()) diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs new file mode 100644 index 0000000..76574af --- /dev/null +++ b/src/mcp/mod.rs @@ -0,0 +1,6 @@ +pub mod server; +pub mod tools; +pub mod types; + +pub use server::McpServer; +pub use types::*; \ No newline at end of file diff --git a/src/mcp/server.rs b/src/mcp/server.rs new file mode 100644 index 0000000..db70a8b --- /dev/null +++ b/src/mcp/server.rs @@ -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, +} + +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 { + 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 { + Json(json!({ + "status": "healthy", + "timestamp": chrono::Utc::now().to_rfc3339() + })) +} + +async fn list_tools() -> Json { + let tools = BlogTools::get_tools(); + Json(json!({ + "tools": tools + })) +} + +async fn call_tool( + State(state): State, + Json(request): Json, +) -> Result, 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, + }), + })), + } +} \ No newline at end of file diff --git a/src/mcp/tools.rs b/src/mcp/tools.rs new file mode 100644 index 0000000..74446f9 --- /dev/null +++ b/src/mcp/tools.rs @@ -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 { + 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::() + .split_whitespace() + .collect::>() + .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 { + 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 { + // 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 { + 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 { + 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"] + }), + }, + ] + } +} \ No newline at end of file diff --git a/src/mcp/types.rs b/src/mcp/types.rs new file mode 100644 index 0000000..4fddecc --- /dev/null +++ b/src/mcp/types.rs @@ -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, + pub method: String, + pub params: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct McpResponse { + pub jsonrpc: String, + pub id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct McpError { + pub code: i32, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +#[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, + #[serde(rename = "isError", skip_serializing_if = "Option::is_none")] + pub is_error: Option, +} + +#[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>, + pub slug: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ListPostsRequest { + pub limit: Option, + pub offset: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PostInfo { + pub title: String, + pub slug: String, + pub date: String, + pub tags: Vec, + pub url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BuildRequest { + pub enable_ai: Option, + pub translate: Option, +} \ No newline at end of file diff --git a/test-blog/config.toml b/test-blog/config.toml new file mode 100644 index 0000000..3bdfed4 --- /dev/null +++ b/test-blog/config.toml @@ -0,0 +1,21 @@ +[site] +title = "My Blog" +description = "A blog powered by ailog" +base_url = "https://example.com" +language = "ja" + +[build] +highlight_code = true +minify = false + +[ai] +enabled = true +auto_translate = true +comment_moderation = true +# api_key = "your-openai-api-key" +# gpt_endpoint = "https://api.openai.com/v1/chat/completions" + +# [ai.atproto_config] +# client_id = "https://example.com/client-metadata.json" +# redirect_uri = "https://example.com/callback" +# handle_resolver = "https://bsky.social" diff --git a/test-blog/content/posts/2025-06-06-ai統合ブログシステムの紹介.md b/test-blog/content/posts/2025-06-06-ai統合ブログシステムの紹介.md new file mode 100644 index 0000000..0c075fa --- /dev/null +++ b/test-blog/content/posts/2025-06-06-ai統合ブログシステムの紹介.md @@ -0,0 +1,39 @@ +--- +title: AI統合ブログシステムの紹介 +date: 2025-06-06 +tags: [AI, 技術, ブログ] +--- + +# AI統合ブログシステムの紹介 + +ai.logは、静的ブログジェネレーターにAI機能を統合した革新的なシステムです。このシステムは存在子理論に基づき、現実の個人の唯一性をデジタル世界で担保することを目指しています。 + +## 主な機能 + +### 1. AI記事編集・強化 +- 文法エラーの自動修正 +- 読みやすさの向上 +- 関連情報の追加提案 + +### 2. 自動翻訳機能 +日本語で書かれた記事を自動的に英語に翻訳し、グローバルな読者にリーチできます。Markdownフォーマットを保持したまま、自然な翻訳を提供します。 + +### 3. AIコメントシステム +AI(存在子)が各記事に対して独自の視点からコメントを追加します。これにより、読者に新たな洞察を提供します。 + +### 4. atproto統合 +分散型SNSプロトコルであるatprotoと統合し、以下を実現します: +- OAuth認証によるセキュアなログイン +- コメントデータの分散管理 +- ユーザーデータ主権の確立 + +## 技術スタック + +- **言語**: Rust +- **AI**: OpenAI GPT API +- **認証**: atproto OAuth 2.0 +- **デプロイ**: GitHub Actions + Cloudflare Pages + +## 今後の展望 + +ai.logは、単なるブログツールを超えて、AIと人間が共創する新しいコンテンツプラットフォームを目指しています。存在子理論に基づく唯一性の担保により、デジタル世界での個人のアイデンティティを守りながら、AIによる創造性の拡張を実現します。 \ No newline at end of file diff --git a/test-blog/content/posts/welcome.md b/test-blog/content/posts/welcome.md new file mode 100644 index 0000000..052e272 --- /dev/null +++ b/test-blog/content/posts/welcome.md @@ -0,0 +1,32 @@ +--- +title: "Welcome to ailog" +date: 2025-01-06 +tags: ["welcome", "ailog"] +--- + +# Welcome to ailog + +This is your first post powered by **ailog** - a static blog generator with AI features. + +## Features + +- Fast static site generation +- Markdown support with frontmatter +- AI-powered features (coming soon) +- atproto integration for comments + +## Getting Started + +Create new posts with: + +```bash +ailog new "My New Post" +``` + +Build your blog with: + +```bash +ailog build +``` + +Happy blogging! \ No newline at end of file diff --git a/test-blog/public/css/style.css b/test-blog/public/css/style.css new file mode 100644 index 0000000..b50ebe7 --- /dev/null +++ b/test-blog/public/css/style.css @@ -0,0 +1,58 @@ +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + line-height: 1.6; + color: #333; + max-width: 800px; + margin: 0 auto; + padding: 20px; +} + +header { + margin-bottom: 40px; + border-bottom: 1px solid #eee; + padding-bottom: 20px; +} + +header h1 { + margin: 0; +} + +header h1 a { + color: #333; + text-decoration: none; +} + +.post-list { + list-style: none; + padding: 0; +} + +.post-list li { + margin-bottom: 15px; +} + +.post-list time { + color: #666; + font-size: 0.9em; + margin-left: 10px; +} + +article time { + color: #666; + display: block; + margin-bottom: 20px; +} + +pre { + background-color: #f4f4f4; + padding: 15px; + border-radius: 5px; + overflow-x: auto; +} + +code { + background-color: #f4f4f4; + padding: 2px 5px; + border-radius: 3px; + font-family: 'Consolas', 'Monaco', monospace; +} \ No newline at end of file diff --git a/test-blog/public/index.html b/test-blog/public/index.html new file mode 100644 index 0000000..c1a4093 --- /dev/null +++ b/test-blog/public/index.html @@ -0,0 +1,38 @@ + + + + + + My Blog + + + +
+

My Blog

+

A blog powered by ailog

+
+ +
+ +

Recent Posts

+ + +
+ +
+

© 2025 My Blog

+
+ + \ No newline at end of file diff --git a/test-blog/public/posts/2025-06-06-ai統合ブログシステムの紹介-en.html b/test-blog/public/posts/2025-06-06-ai統合ブログシステムの紹介-en.html new file mode 100644 index 0000000..5dd80c5 --- /dev/null +++ b/test-blog/public/posts/2025-06-06-ai統合ブログシステムの紹介-en.html @@ -0,0 +1,60 @@ + + + + + + AI統合ブログシステムの紹介 - My Blog + + + +
+

My Blog

+

A blog powered by ailog

+
+ +
+ +
+

AI統合ブログシステムの紹介

+ +
+

AI統合ブログシステムの紹介

+

ai.logは、静的ブログジェネレーターにAI機能を統合した革新的なシステムです。このシステムは存在子理論に基づき、現実の個人の唯一性をデジタル世界で担保することを目指しています。

+

主な機能

+

1. AI記事編集・強化

+
    +
  • 文法エラーの自動修正
  • +
  • 読みやすさの向上
  • +
  • 関連情報の追加提案
  • +
+

2. 自動翻訳機能

+

日本語で書かれた記事を自動的に英語に翻訳し、グローバルな読者にリーチできます。Markdownフォーマットを保持したまま、自然な翻訳を提供します。

+

3. AIコメントシステム

+

AI(存在子)が各記事に対して独自の視点からコメントを追加します。これにより、読者に新たな洞察を提供します。

+

4. atproto統合

+

分散型SNSプロトコルであるatprotoと統合し、以下を実現します:

+
    +
  • OAuth認証によるセキュアなログイン
  • +
  • コメントデータの分散管理
  • +
  • ユーザーデータ主権の確立
  • +
+

技術スタック

+
    +
  • 言語: Rust
  • +
  • AI: OpenAI GPT API
  • +
  • 認証: atproto OAuth 2.0
  • +
  • デプロイ: GitHub Actions + Cloudflare Pages
  • +
+

今後の展望

+

ai.logは、単なるブログツールを超えて、AIと人間が共創する新しいコンテンツプラットフォームを目指しています。存在子理論に基づく唯一性の担保により、デジタル世界での個人のアイデンティティを守りながら、AIによる創造性の拡張を実現します。

+ +
+
+ +
+ +
+

© 2025 My Blog

+
+ + \ No newline at end of file diff --git a/test-blog/public/posts/2025-06-06-ai統合ブログシステムの紹介.html b/test-blog/public/posts/2025-06-06-ai統合ブログシステムの紹介.html new file mode 100644 index 0000000..5dd80c5 --- /dev/null +++ b/test-blog/public/posts/2025-06-06-ai統合ブログシステムの紹介.html @@ -0,0 +1,60 @@ + + + + + + AI統合ブログシステムの紹介 - My Blog + + + +
+

My Blog

+

A blog powered by ailog

+
+ +
+ +
+

AI統合ブログシステムの紹介

+ +
+

AI統合ブログシステムの紹介

+

ai.logは、静的ブログジェネレーターにAI機能を統合した革新的なシステムです。このシステムは存在子理論に基づき、現実の個人の唯一性をデジタル世界で担保することを目指しています。

+

主な機能

+

1. AI記事編集・強化

+
    +
  • 文法エラーの自動修正
  • +
  • 読みやすさの向上
  • +
  • 関連情報の追加提案
  • +
+

2. 自動翻訳機能

+

日本語で書かれた記事を自動的に英語に翻訳し、グローバルな読者にリーチできます。Markdownフォーマットを保持したまま、自然な翻訳を提供します。

+

3. AIコメントシステム

+

AI(存在子)が各記事に対して独自の視点からコメントを追加します。これにより、読者に新たな洞察を提供します。

+

4. atproto統合

+

分散型SNSプロトコルであるatprotoと統合し、以下を実現します:

+
    +
  • OAuth認証によるセキュアなログイン
  • +
  • コメントデータの分散管理
  • +
  • ユーザーデータ主権の確立
  • +
+

技術スタック

+
    +
  • 言語: Rust
  • +
  • AI: OpenAI GPT API
  • +
  • 認証: atproto OAuth 2.0
  • +
  • デプロイ: GitHub Actions + Cloudflare Pages
  • +
+

今後の展望

+

ai.logは、単なるブログツールを超えて、AIと人間が共創する新しいコンテンツプラットフォームを目指しています。存在子理論に基づく唯一性の担保により、デジタル世界での個人のアイデンティティを守りながら、AIによる創造性の拡張を実現します。

+ +
+
+ +
+ +
+

© 2025 My Blog

+
+ + \ No newline at end of file diff --git a/test-blog/public/posts/welcome-en.html b/test-blog/public/posts/welcome-en.html new file mode 100644 index 0000000..963d501 --- /dev/null +++ b/test-blog/public/posts/welcome-en.html @@ -0,0 +1,48 @@ + + + + + + Welcome to ailog - My Blog + + + +
+

My Blog

+

A blog powered by ailog

+
+ +
+ +
+

Welcome to ailog

+ +
+

Welcome to ailog

+

This is your first post powered by ailog - a static blog generator with AI features.

+

Features

+
    +
  • Fast static site generation
  • +
  • Markdown support with frontmatter
  • +
  • AI-powered features (coming soon)
  • +
  • atproto integration for comments
  • +
+

Getting Started

+

Create new posts with:

+
ailog new "My New Post"
+
+

Build your blog with:

+
ailog build
+
+

Happy blogging!

+ +
+
+ +
+ +
+

© 2025 My Blog

+
+ + \ No newline at end of file diff --git a/test-blog/public/posts/welcome.html b/test-blog/public/posts/welcome.html new file mode 100644 index 0000000..963d501 --- /dev/null +++ b/test-blog/public/posts/welcome.html @@ -0,0 +1,48 @@ + + + + + + Welcome to ailog - My Blog + + + +
+

My Blog

+

A blog powered by ailog

+
+ +
+ +
+

Welcome to ailog

+ +
+

Welcome to ailog

+

This is your first post powered by ailog - a static blog generator with AI features.

+

Features

+
    +
  • Fast static site generation
  • +
  • Markdown support with frontmatter
  • +
  • AI-powered features (coming soon)
  • +
  • atproto integration for comments
  • +
+

Getting Started

+

Create new posts with:

+
ailog new "My New Post"
+
+

Build your blog with:

+
ailog build
+
+

Happy blogging!

+ +
+
+ +
+ +
+

© 2025 My Blog

+
+ + \ No newline at end of file diff --git a/test-blog/static/css/style.css b/test-blog/static/css/style.css new file mode 100644 index 0000000..b50ebe7 --- /dev/null +++ b/test-blog/static/css/style.css @@ -0,0 +1,58 @@ +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + line-height: 1.6; + color: #333; + max-width: 800px; + margin: 0 auto; + padding: 20px; +} + +header { + margin-bottom: 40px; + border-bottom: 1px solid #eee; + padding-bottom: 20px; +} + +header h1 { + margin: 0; +} + +header h1 a { + color: #333; + text-decoration: none; +} + +.post-list { + list-style: none; + padding: 0; +} + +.post-list li { + margin-bottom: 15px; +} + +.post-list time { + color: #666; + font-size: 0.9em; + margin-left: 10px; +} + +article time { + color: #666; + display: block; + margin-bottom: 20px; +} + +pre { + background-color: #f4f4f4; + padding: 15px; + border-radius: 5px; + overflow-x: auto; +} + +code { + background-color: #f4f4f4; + padding: 2px 5px; + border-radius: 3px; + font-family: 'Consolas', 'Monaco', monospace; +} \ No newline at end of file diff --git a/test-blog/templates/base.html b/test-blog/templates/base.html new file mode 100644 index 0000000..0fcda9e --- /dev/null +++ b/test-blog/templates/base.html @@ -0,0 +1,23 @@ + + + + + + {% block title %}{{ config.title }}{% endblock %} + + + +
+

{{ config.title }}

+

{{ config.description }}

+
+ +
+ {% block content %}{% endblock %} +
+ +
+

© 2025 {{ config.title }}

+
+ + \ No newline at end of file diff --git a/test-blog/templates/index.html b/test-blog/templates/index.html new file mode 100644 index 0000000..98c510a --- /dev/null +++ b/test-blog/templates/index.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} + +{% block content %} +

Recent Posts

+
    + {% for post in posts %} +
  • + {{ post.title }} + +
  • + {% endfor %} +
+{% endblock %} \ No newline at end of file diff --git a/test-blog/templates/post.html b/test-blog/templates/post.html new file mode 100644 index 0000000..3db5a65 --- /dev/null +++ b/test-blog/templates/post.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} + +{% block title %}{{ post.title }} - {{ config.title }}{% endblock %} + +{% block content %} +
+

{{ post.title }}

+ +
+ {{ post.content | safe }} +
+
+{% endblock %} \ No newline at end of file