update
This commit is contained in:
parent
02dd69840d
commit
a9dca2fe38
@ -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": []
|
||||
}
|
||||
|
@ -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"
|
22
README.md
22
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
|
||||
|
||||
|
142
mcp_integration.md
Normal file
142
mcp_integration.md
Normal file
@ -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からの認証済みリクエストのみ処理
|
||||
- ファイルアクセスは指定されたブログディレクトリ内に制限
|
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>,
|
||||
}
|
21
test-blog/config.toml
Normal file
21
test-blog/config.toml
Normal file
@ -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"
|
39
test-blog/content/posts/2025-06-06-ai統合ブログシステムの紹介.md
Normal file
39
test-blog/content/posts/2025-06-06-ai統合ブログシステムの紹介.md
Normal file
@ -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による創造性の拡張を実現します。
|
32
test-blog/content/posts/welcome.md
Normal file
32
test-blog/content/posts/welcome.md
Normal file
@ -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!
|
58
test-blog/public/css/style.css
Normal file
58
test-blog/public/css/style.css
Normal file
@ -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;
|
||||
}
|
38
test-blog/public/index.html
Normal file
38
test-blog/public/index.html
Normal file
@ -0,0 +1,38 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>My Blog</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1><a href="/">My Blog</a></h1>
|
||||
<p>A blog powered by ailog</p>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
|
||||
<h2>Recent Posts</h2>
|
||||
<ul class="post-list">
|
||||
|
||||
<li>
|
||||
<a href="/posts/2025-06-06-ai統合ブログシステムの紹介.html">AI統合ブログシステムの紹介</a>
|
||||
<time>2025-06-06</time>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a href="/posts/welcome.html">Welcome to ailog</a>
|
||||
<time>2025-01-06</time>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>© 2025 My Blog</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
60
test-blog/public/posts/2025-06-06-ai統合ブログシステムの紹介-en.html
Normal file
60
test-blog/public/posts/2025-06-06-ai統合ブログシステムの紹介-en.html
Normal file
@ -0,0 +1,60 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AI統合ブログシステムの紹介 - My Blog</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1><a href="/">My Blog</a></h1>
|
||||
<p>A blog powered by ailog</p>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
|
||||
<article>
|
||||
<h1>AI統合ブログシステムの紹介</h1>
|
||||
<time>2025-06-06</time>
|
||||
<div class="content">
|
||||
<h1>AI統合ブログシステムの紹介</h1>
|
||||
<p>ai.logは、静的ブログジェネレーターにAI機能を統合した革新的なシステムです。このシステムは存在子理論に基づき、現実の個人の唯一性をデジタル世界で担保することを目指しています。</p>
|
||||
<h2>主な機能</h2>
|
||||
<h3>1. AI記事編集・強化</h3>
|
||||
<ul>
|
||||
<li>文法エラーの自動修正</li>
|
||||
<li>読みやすさの向上</li>
|
||||
<li>関連情報の追加提案</li>
|
||||
</ul>
|
||||
<h3>2. 自動翻訳機能</h3>
|
||||
<p>日本語で書かれた記事を自動的に英語に翻訳し、グローバルな読者にリーチできます。Markdownフォーマットを保持したまま、自然な翻訳を提供します。</p>
|
||||
<h3>3. AIコメントシステム</h3>
|
||||
<p>AI(存在子)が各記事に対して独自の視点からコメントを追加します。これにより、読者に新たな洞察を提供します。</p>
|
||||
<h3>4. atproto統合</h3>
|
||||
<p>分散型SNSプロトコルであるatprotoと統合し、以下を実現します:</p>
|
||||
<ul>
|
||||
<li>OAuth認証によるセキュアなログイン</li>
|
||||
<li>コメントデータの分散管理</li>
|
||||
<li>ユーザーデータ主権の確立</li>
|
||||
</ul>
|
||||
<h2>技術スタック</h2>
|
||||
<ul>
|
||||
<li><strong>言語</strong>: Rust</li>
|
||||
<li><strong>AI</strong>: OpenAI GPT API</li>
|
||||
<li><strong>認証</strong>: atproto OAuth 2.0</li>
|
||||
<li><strong>デプロイ</strong>: GitHub Actions + Cloudflare Pages</li>
|
||||
</ul>
|
||||
<h2>今後の展望</h2>
|
||||
<p>ai.logは、単なるブログツールを超えて、AIと人間が共創する新しいコンテンツプラットフォームを目指しています。存在子理論に基づく唯一性の担保により、デジタル世界での個人のアイデンティティを守りながら、AIによる創造性の拡張を実現します。</p>
|
||||
|
||||
</div>
|
||||
</article>
|
||||
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>© 2025 My Blog</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
60
test-blog/public/posts/2025-06-06-ai統合ブログシステムの紹介.html
Normal file
60
test-blog/public/posts/2025-06-06-ai統合ブログシステムの紹介.html
Normal file
@ -0,0 +1,60 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AI統合ブログシステムの紹介 - My Blog</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1><a href="/">My Blog</a></h1>
|
||||
<p>A blog powered by ailog</p>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
|
||||
<article>
|
||||
<h1>AI統合ブログシステムの紹介</h1>
|
||||
<time>2025-06-06</time>
|
||||
<div class="content">
|
||||
<h1>AI統合ブログシステムの紹介</h1>
|
||||
<p>ai.logは、静的ブログジェネレーターにAI機能を統合した革新的なシステムです。このシステムは存在子理論に基づき、現実の個人の唯一性をデジタル世界で担保することを目指しています。</p>
|
||||
<h2>主な機能</h2>
|
||||
<h3>1. AI記事編集・強化</h3>
|
||||
<ul>
|
||||
<li>文法エラーの自動修正</li>
|
||||
<li>読みやすさの向上</li>
|
||||
<li>関連情報の追加提案</li>
|
||||
</ul>
|
||||
<h3>2. 自動翻訳機能</h3>
|
||||
<p>日本語で書かれた記事を自動的に英語に翻訳し、グローバルな読者にリーチできます。Markdownフォーマットを保持したまま、自然な翻訳を提供します。</p>
|
||||
<h3>3. AIコメントシステム</h3>
|
||||
<p>AI(存在子)が各記事に対して独自の視点からコメントを追加します。これにより、読者に新たな洞察を提供します。</p>
|
||||
<h3>4. atproto統合</h3>
|
||||
<p>分散型SNSプロトコルであるatprotoと統合し、以下を実現します:</p>
|
||||
<ul>
|
||||
<li>OAuth認証によるセキュアなログイン</li>
|
||||
<li>コメントデータの分散管理</li>
|
||||
<li>ユーザーデータ主権の確立</li>
|
||||
</ul>
|
||||
<h2>技術スタック</h2>
|
||||
<ul>
|
||||
<li><strong>言語</strong>: Rust</li>
|
||||
<li><strong>AI</strong>: OpenAI GPT API</li>
|
||||
<li><strong>認証</strong>: atproto OAuth 2.0</li>
|
||||
<li><strong>デプロイ</strong>: GitHub Actions + Cloudflare Pages</li>
|
||||
</ul>
|
||||
<h2>今後の展望</h2>
|
||||
<p>ai.logは、単なるブログツールを超えて、AIと人間が共創する新しいコンテンツプラットフォームを目指しています。存在子理論に基づく唯一性の担保により、デジタル世界での個人のアイデンティティを守りながら、AIによる創造性の拡張を実現します。</p>
|
||||
|
||||
</div>
|
||||
</article>
|
||||
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>© 2025 My Blog</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
48
test-blog/public/posts/welcome-en.html
Normal file
48
test-blog/public/posts/welcome-en.html
Normal file
@ -0,0 +1,48 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Welcome to ailog - My Blog</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1><a href="/">My Blog</a></h1>
|
||||
<p>A blog powered by ailog</p>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
|
||||
<article>
|
||||
<h1>Welcome to ailog</h1>
|
||||
<time>2025-01-06</time>
|
||||
<div class="content">
|
||||
<h1>Welcome to ailog</h1>
|
||||
<p>This is your first post powered by <strong>ailog</strong> - a static blog generator with AI features.</p>
|
||||
<h2>Features</h2>
|
||||
<ul>
|
||||
<li>Fast static site generation</li>
|
||||
<li>Markdown support with frontmatter</li>
|
||||
<li>AI-powered features (coming soon)</li>
|
||||
<li>atproto integration for comments</li>
|
||||
</ul>
|
||||
<h2>Getting Started</h2>
|
||||
<p>Create new posts with:</p>
|
||||
<pre><code><span style="color:#8fa1b3;">ailog</span><span style="color:#c0c5ce;"> new "</span><span style="color:#a3be8c;">My New Post</span><span style="color:#c0c5ce;">"</span>
|
||||
</code></pre>
|
||||
<p>Build your blog with:</p>
|
||||
<pre><code><span style="color:#8fa1b3;">ailog</span><span style="color:#c0c5ce;"> build</span>
|
||||
</code></pre>
|
||||
<p>Happy blogging!</p>
|
||||
|
||||
</div>
|
||||
</article>
|
||||
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>© 2025 My Blog</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
48
test-blog/public/posts/welcome.html
Normal file
48
test-blog/public/posts/welcome.html
Normal file
@ -0,0 +1,48 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Welcome to ailog - My Blog</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1><a href="/">My Blog</a></h1>
|
||||
<p>A blog powered by ailog</p>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
|
||||
<article>
|
||||
<h1>Welcome to ailog</h1>
|
||||
<time>2025-01-06</time>
|
||||
<div class="content">
|
||||
<h1>Welcome to ailog</h1>
|
||||
<p>This is your first post powered by <strong>ailog</strong> - a static blog generator with AI features.</p>
|
||||
<h2>Features</h2>
|
||||
<ul>
|
||||
<li>Fast static site generation</li>
|
||||
<li>Markdown support with frontmatter</li>
|
||||
<li>AI-powered features (coming soon)</li>
|
||||
<li>atproto integration for comments</li>
|
||||
</ul>
|
||||
<h2>Getting Started</h2>
|
||||
<p>Create new posts with:</p>
|
||||
<pre><code><span style="color:#8fa1b3;">ailog</span><span style="color:#c0c5ce;"> new "</span><span style="color:#a3be8c;">My New Post</span><span style="color:#c0c5ce;">"</span>
|
||||
</code></pre>
|
||||
<p>Build your blog with:</p>
|
||||
<pre><code><span style="color:#8fa1b3;">ailog</span><span style="color:#c0c5ce;"> build</span>
|
||||
</code></pre>
|
||||
<p>Happy blogging!</p>
|
||||
|
||||
</div>
|
||||
</article>
|
||||
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>© 2025 My Blog</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
58
test-blog/static/css/style.css
Normal file
58
test-blog/static/css/style.css
Normal file
@ -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;
|
||||
}
|
23
test-blog/templates/base.html
Normal file
23
test-blog/templates/base.html
Normal file
@ -0,0 +1,23 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ config.language }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}{{ config.title }}{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1><a href="/">{{ config.title }}</a></h1>
|
||||
<p>{{ config.description }}</p>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>© 2025 {{ config.title }}</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
13
test-blog/templates/index.html
Normal file
13
test-blog/templates/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h2>Recent Posts</h2>
|
||||
<ul class="post-list">
|
||||
{% for post in posts %}
|
||||
<li>
|
||||
<a href="{{ post.url }}">{{ post.title }}</a>
|
||||
<time>{{ post.date }}</time>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endblock %}
|
13
test-blog/templates/post.html
Normal file
13
test-blog/templates/post.html
Normal file
@ -0,0 +1,13 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ post.title }} - {{ config.title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<article>
|
||||
<h1>{{ post.title }}</h1>
|
||||
<time>{{ post.date }}</time>
|
||||
<div class="content">
|
||||
{{ post.content | safe }}
|
||||
</div>
|
||||
</article>
|
||||
{% endblock %}
|
Loading…
x
Reference in New Issue
Block a user