From c12d42882cfbd66716cb1b10311d47d7c5f37e3d Mon Sep 17 00:00:00 2001 From: syui Date: Sun, 15 Jun 2025 15:06:50 +0900 Subject: [PATCH] test update --- .claude/settings.local.json | 3 +- README.md | 1130 ++++++++++++--------------- ai_prompt.txt | 1 + bin/ailog-generate.zsh | 173 ++++ my-blog/config.toml | 6 +- my-blog/layouts/_default/index.json | 7 + my-blog/static/index.html | 2 +- my-blog/templates/oauth-assets.html | 2 +- oauth/.env.production | 12 +- oauth/src/App.tsx | 154 +++- oauth/src/config/app.ts | 41 +- src/commands/auth.rs | 96 ++- src/commands/oauth.rs | 28 +- src/commands/stream.rs | 366 ++++++++- src/generator.rs | 77 +- src/main.rs | 7 +- 16 files changed, 1363 insertions(+), 742 deletions(-) create mode 100644 ai_prompt.txt create mode 100755 bin/ailog-generate.zsh create mode 100644 my-blog/layouts/_default/index.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 5660adb..b351d71 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -46,7 +46,8 @@ "Bash(git commit:*)", "Bash(git push:*)", "Bash(git tag:*)", - "Bash(../bin/ailog:*)" + "Bash(../bin/ailog:*)", + "Bash(../target/release/ailog oauth build:*)" ], "deny": [] } diff --git a/README.md b/README.md index 6e06672..466cef0 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,367 @@ AI-powered static blog generator with ATProto integration, part of the ai.ai ecosystem. -## 🎯 Gitea Action Usage +## 🚀 Quick Start -Use ailog in your Gitea Actions workflow: +### Installation & Setup + +```bash +# 1. Clone repository +git clone https://git.syui.ai/ai/log +cd log + +# 2. Build ailog +cargo build --release + +# 3. Initialize blog +./target/release/ailog init my-blog + +# 4. Create your first post +./target/release/ailog new "My First Post" + +# 5. Build static site +./target/release/ailog build + +# 6. Serve locally +./target/release/ailog serve +``` + +### Install via Cargo + +```bash +cargo install --path . +# Now you can use `ailog` command globally +``` + +## 📖 Core Commands + +### Blog Management + +```bash +# Project setup +ailog init # Initialize new blog project +ailog new # Create new blog post +ailog build # Generate static site with JSON index +ailog serve # Start development server +ailog clean # Clean build artifacts + +# ATProto authentication +ailog auth init # Setup ATProto credentials +ailog auth status # Check authentication status +ailog auth logout # Clear credentials + +# OAuth app build +ailog oauth build <project-dir> # Build OAuth comment system +``` + +### Stream & AI Features + +```bash +# Start monitoring & AI generation +ailog stream start --ai-generate # Monitor blog + auto-generate AI content +ailog stream start --daemon # Run as background daemon +ailog stream status # Check stream status +ailog stream stop # Stop monitoring +ailog stream test # Test ATProto API access +``` + +### Documentation & Translation + +```bash +# Generate documentation +ailog doc readme --with-ai # Generate enhanced README +ailog doc api --output ./docs # Generate API documentation +ailog doc structure --include-deps # Analyze project structure + +# AI-powered translation +ailog doc translate --input README.md --target-lang en +ailog doc translate --input docs/guide.ja.md --target-lang en --model qwen2.5:latest +``` + +## 🏗️ Architecture + +### Project Structure + +``` +ai.log/ +├── src/ # Rust static blog generator +│ ├── commands/ # CLI command implementations +│ ├── generator.rs # Core blog generation + JSON index +│ ├── mcp/ # MCP server integration +│ └── main.rs # CLI entry point +├── my-blog/ # Your blog content +│ ├── content/posts/ # Markdown blog posts +│ ├── templates/ # Tera templates +│ ├── static/ # Static assets +│ └── public/ # Generated site output +├── oauth/ # ATProto comment system +│ ├── src/ # TypeScript OAuth app +│ ├── dist/ # Built OAuth assets +│ └── package.json # Node.js dependencies +└── target/ # Rust build output +``` + +### Data Flow + +``` +Blog Posts (Markdown) → ailog build → public/ + ├── Static HTML pages + └── index.json (API) + ↓ +ailog stream start --ai-generate → Monitor index.json + ↓ +New posts detected → Ollama AI → ATProto records + ├── ai.syui.log.chat.lang (translations) + └── ai.syui.log.chat.comment (AI comments) + ↓ +OAuth app → Display AI-generated content +``` + +## 🤖 AI Integration + +### AI Content Generation + +The `--ai-generate` flag enables automatic AI content generation: + +1. **Blog Monitoring**: Monitors `index.json` every 5 minutes +2. **Duplicate Prevention**: Checks existing ATProto collections +3. **AI Generation**: Uses Ollama (gemma3:4b) for translations & comments +4. **ATProto Storage**: Saves to derived collections (`base.chat.lang`, `base.chat.comment`) + +```bash +# Start AI generation monitor +ailog stream start --ai-generate + +# Output: +# 🤖 Starting AI content generation monitor... +# 📡 Blog host: https://syui.ai +# 🧠 Ollama host: https://ollama.syui.ai +# 🔍 Checking for new blog posts... +# ✅ Generated translation for: 静的サイトジェネレータを作った +# ✅ Generated comment for: 静的サイトジェネレータを作った +``` + +### Collection Management + +ailog uses a **simplified collection structure** based on a single base collection name: + +```bash +# Single environment variable controls all collections (unified naming) +export VITE_OAUTH_COLLECTION="ai.syui.log" + +# Automatically derives: +# - ai.syui.log (comments) +# - ai.syui.log.user (user management) +# - ai.syui.log.chat.lang (AI translations) +# - ai.syui.log.chat.comment (AI comments) +``` + +**Benefits:** +- ✅ **Simple**: One variable instead of 5+ +- ✅ **Consistent**: All collections follow the same pattern +- ✅ **Manageable**: Easy systemd/production configuration + +### Ask AI Feature + +Interactive AI chat integrated into blog pages: + +```bash +# 1. Setup Ollama +brew install ollama +ollama pull gemma2:2b + +# 2. Start with CORS support +OLLAMA_ORIGINS="https://example.com" ollama serve + +# 3. Configure AI DID in templates/base.html +const aiConfig = { + systemPrompt: 'You are a helpful AI assistant.', + aiDid: 'did:plc:your-ai-bot-did' +}; +``` + +## 🌐 ATProto Integration + +### OAuth Comment System + +The OAuth app provides ATProto-authenticated commenting: + +```bash +# 1. Build OAuth app +cd oauth +npm install +npm run build + +# 2. Configure for production +ailog oauth build my-blog # Auto-generates .env.production + +# 3. Deploy OAuth assets +# Assets are automatically copied to public/ during ailog build +``` + +### Authentication Setup + +```bash +# Initialize ATProto authentication +ailog auth init + +# Input required: +# - Handle (e.g., your.handle.bsky.social) +# - Access JWT +# - Refresh JWT + +# Check status +ailog auth status +``` + +### Collection Structure + +All ATProto collections are **automatically derived** from a single base name: + +``` +Base Collection: "ai.syui.log" +├── ai.syui.log (user comments) +├── ai.syui.log.user (registered commenters) +└── ai.syui.log.chat/ + ├── ai.syui.log.chat.lang (AI translations) + └── ai.syui.log.chat.comment (AI comments) +``` + +**Configuration Priority:** +1. Environment variable: `VITE_OAUTH_COLLECTION` (unified) +2. config.toml: `[oauth] collection = "..."` +3. Auto-generated from domain (e.g., `log.syui.ai` → `ai.syui.log`) +4. Default: `ai.syui.log` + +### Stream Monitoring + +```bash +# Monitor ATProto streams for comments +ailog stream start + +# Enable AI generation alongside monitoring +ailog stream start --ai-generate --daemon +``` + +## 📱 OAuth App Features + +The OAuth TypeScript app provides: + +### Comment System +- **Real-time Comments**: ATProto-authenticated commenting +- **User Management**: Automatic user registration +- **Mobile Responsive**: Optimized for all devices +- **JSON View**: Technical record inspection + +### AI Content Display +- **Lang: EN Tab**: AI-generated English translations +- **AI Comment Tab**: AI-generated blog insights +- **Admin Records**: Fetches from admin DID collections +- **Real-time Updates**: Live content refresh + +### Setup & Configuration + +```bash +cd oauth + +# Development +npm run dev + +# Production build +npm run build + +# Preview production +npm run preview +``` + +**Environment Variables:** +```bash +# Production (.env.production - auto-generated by ailog oauth build) +VITE_APP_HOST=https://syui.ai +VITE_OAUTH_CLIENT_ID=https://syui.ai/client-metadata.json +VITE_OAUTH_REDIRECT_URI=https://syui.ai/oauth/callback +VITE_ADMIN_DID=did:plc:uqzpqmrjnptsxezjx4xuh2mn + +# Simplified collection configuration (single base collection) +VITE_OAUTH_COLLECTION=ai.syui.log + +# AI Configuration +VITE_AI_ENABLED=true +VITE_AI_ASK_AI=true +VITE_AI_PROVIDER=ollama +# ... (other AI settings) +``` + +## 🔧 Advanced Features + +### JSON Index Generation + +Every `ailog build` generates `/public/index.json`: + +```json +[ + { + "title": "静的サイトジェネレータを作った", + "href": "https://syui.ai/posts/2025-06-06-ailog.html", + "formated_time": "Thu Jun 12, 2025", + "utc_time": "2025-06-12T00:00:00Z", + "tags": ["blog", "rust", "mcp", "atp"], + "contents": "Plain text content...", + "description": "Excerpt...", + "categories": [] + } +] +``` + +This enables: +- **API Access**: Programmatic blog content access +- **Stream Monitoring**: AI generation triggers +- **Search Integration**: Full-text search capabilities + +### Translation System + +AI-powered document translation with Ollama: + +```bash +# Basic translation +ailog doc translate --input README.md --target-lang en + +# Advanced options +ailog doc translate \ + --input docs/guide.ja.md \ + --target-lang en \ + --source-lang ja \ + --model qwen2.5:latest \ + --output docs/guide.en.md +``` + +**Features:** +- **Markdown-aware**: Preserves code blocks, links, tables +- **Multiple models**: qwen2.5, gemma3, etc. +- **Auto-detection**: Detects Japanese content automatically +- **Structure preservation**: Maintains document formatting + +### MCP Server Integration + +```bash +# Start MCP server for ai.gpt integration +ailog mcp --port 8002 + +# Available tools: +# - create_blog_post +# - list_blog_posts +# - build_blog +# - get_post_content +# - translate_document +# - generate_documentation +``` + +## 🚀 Deployment + +### GitHub Actions ```yaml -name: Deploy Blog +name: Deploy ai.log Blog on: push: branches: [main] @@ -17,667 +372,188 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: ai/log@v1 + + - name: Setup Rust + uses: actions-rs/toolchain@v1 with: - content-dir: 'content' - output-dir: 'public' - ai-integration: true - atproto-integration: true - - uses: cloudflare/pages-action@v1 + toolchain: stable + + - name: Build ailog + run: cargo build --release + + - name: Build blog + run: | + cd my-blog + ../target/release/ailog build + + - name: Deploy to Cloudflare Pages + uses: cloudflare/pages-action@v1 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} projectName: my-blog - directory: public + directory: my-blog/public ``` -## 🚀 Quick Start - -### Development Setup +### Production Setup ```bash -# 1. Clone and setup -git clone https://git.syui.ai/ai/log -cd log +# 1. Build for production +cargo build --release -# 2. Start development services -./run.zsh serve # Blog development server -./run.zsh c # Cloudflare tunnel (example.com) -./run.zsh o # OAuth web server -./run.zsh co # Comment system monitor +# 2. Setup systemd services +sudo cp systemd/system/ailog-stream.service /etc/systemd/system/ +sudo systemctl enable ailog-stream.service +sudo systemctl start ailog-stream.service -# 3. Start Ollama (for Ask AI) -brew install ollama -ollama pull gemma2:2b -OLLAMA_ORIGINS="https://example.com" ollama serve +# 3. Configure Ollama with CORS +sudo vim /usr/lib/systemd/system/ollama.service +# Add: Environment="OLLAMA_ORIGINS=https://yourdomain.com" + +# 4. Monitor services +journalctl -u ailog-stream.service -f ``` -### Production Deployment - -```bash -# 1. Build static site -hugo - -# 2. Deploy to GitHub Pages -git add . -git commit -m "Update blog" -git push origin main - -# 3. Automatic deployment via GitHub Actions -# Site available at: https://yourusername.github.io/repo-name -``` - -### ATProto Integration - -```bash -# 1. OAuth Client Setup (oauth/client-metadata.json) -{ - "client_id": "https://example.com/client-metadata.json", - "client_name": "ai.log Blog System", - "redirect_uris": ["https://example.com/oauth/callback"], - "scope": "atproto", - "grant_types": ["authorization_code", "refresh_token"], - "response_types": ["code"], - "application_type": "web", - "dpop_bound_access_tokens": true -} - -# 2. Comment System Configuration -# Collection: ai.syui.log (comments) -# User Management: ai.syui.log.user (registered users) - -# 3. Services -./run.zsh o # OAuth authentication server -./run.zsh co # ATProto Jetstream comment monitor -``` - -### Development with run.zsh - -```bash -# Development -./run.zsh serve - -# Production (with Cloudflare Tunnel) -./run.zsh tunnel - -# OAuth app development -./run.zsh o - -# Comment system monitoring -./run.zsh co -``` - -## 📋 Commands - -| Command | Description | -|---------|-------------| -| `./run.zsh c` | Enable Cloudflare tunnel (example.com) for OAuth | -| `./run.zsh o` | Start OAuth web server (port:4173 = example.com) | -| `./run.zsh co` | Start comment system (ATProto stream monitor) | - -## 🏗️ Architecture (Pure Rust + HTML + JS) - -``` -ai.log/ -├── oauth/ # 🎯 OAuth files (protected) -│ ├── oauth-widget-simple.js # Self-contained OAuth widget -│ ├── oauth-simple.html # OAuth authentication page -│ ├── client-metadata.json # ATProto configuration -│ └── README.md # Usage guide -├── my-blog/ # Blog content and templates -│ ├── content/posts/ # Markdown blog posts -│ ├── templates/ # Tera templates -│ ├── static/ # Static assets (OAuth copied here) -│ └── public/ # Generated site (build output) -├── src/ # Rust blog generator -├── scripts/ # Build and deployment scripts -└── run.zsh # 🎯 Main build script -``` - -### ✅ Node.js Dependencies Eliminated -- ❌ `package.json` - Removed -- ❌ `node_modules/` - Removed -- ❌ `npm run build` - Not needed -- ✅ Pure JavaScript OAuth implementation -- ✅ CDN-free, self-contained code -- ✅ Rust-only build process - ---- - -## 📖 Original Features - -[![Rust](https://img.shields.io/badge/Rust-000000?style=for-the-badge&logo=rust&logoColor=white)](https://www.rust-lang.org/) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) - -## 概要 - -ai.logは、[Anthropic Docs](https://docs.anthropic.com/)にインスパイアされたモダンなインターフェースを持つ、次世代静的ブログジェネレーターです。ai.gptとの深い統合、ローカルAI機能、atproto OAuth連携により、従来のブログシステムを超えた体験を提供します。 - -## 主な特徴 - -### 🎨 モダンインターフェース -- **Anthropic Docs風デザイン**: プロフェッショナルで読みやすい -- **Timeline形式**: BlueskyライクなタイムラインUI -- **自動TOC**: 右サイドバーに目次を自動生成 -- **レスポンシブ**: モバイル・デスクトップ対応 - -### 🤖 Ask AI機能 ✅ -- **ローカルAI**: Ollama(gemma2:2b)による質問応答 -- **認証必須**: ATProto OAuth認証でアクセス制御 -- **トップページ限定**: ブログコンテンツに特化した回答 -- **CORS解決済み**: OLLAMA_ORIGINS設定でクロスオリジン問題解消 -- **プロフィール連携**: AIアバターとしてATProtoプロフィール画像表示 -- **レスポンス最適化**: 80文字制限+高いtemperatureで多様な回答 -- **ローディング表示**: Font Awesomeアイコンによる一行ローディング - -### 🔧 Ask AI設定方法 -```bash -# 1. Ollama設定 -brew install ollama -ollama pull gemma2:2b - -# 2. CORS設定で起動 -OLLAMA_ORIGINS="https://example.com" ollama serve - -# 3. AI DID設定 (my-blog/templates/base.html) -const aiConfig = { - systemPrompt: 'You are a helpful AI assistant.', - aiDid: 'did:plc:your-ai-bot-did' -}; -``` - -### 🌐 分散SNS連携 -- **atproto OAuth**: Blueskyアカウントでログイン -- **コメントシステム**: 分散SNSコメント -- **データ主権**: ユーザーがデータを所有 - -### 🔗 エコシステム統合 -- **ai.gpt**: ドキュメント同期・AI機能連携 -- **MCP Server**: ai.gptからの操作をサポート -- **ai.wiki**: 自動ドキュメント同期 - -## Architecture - -### Dual MCP Integration - -**ai.log MCP Server (API Layer)** -- **Role**: Independent blog API -- **Port**: 8002 -- **Location**: `./src/mcp/` -- **Function**: Core blog generation and management - -**ai.gpt Integration (Server Layer)** -- **Role**: AI integration gateway -- **Port**: 8001 (within ai.gpt) -- **Location**: `../src/aigpt/mcp_server.py` -- **Function**: AI memory system + HTTP proxy to ai.log - -### Data Flow -``` -Claude Code → ai.gpt (Server/AI) → ai.log (API/Blog) → Static Site - ↑ ↑ - Memory System File Operations - Relationship AI Markdown Processing - Context Analysis Template Rendering -``` - -## Features - -- **Static Blog Generation**: Inspired by Zola, built with Rust -- **AI-Powered Content**: Memory-driven article generation via ai.gpt -- **🌍 Ollama Translation**: Multi-language markdown translation with structure preservation -- **atproto Integration**: OAuth authentication and comment system (planned) -- **MCP Integration**: Seamless Claude Code workflow - -## Installation - -```bash -cargo install ailog -``` - -## Usage - -### Standalone Mode - -```bash -# Initialize a new blog -ailog init myblog - -# Create a new post -ailog new "My First Post" - -# Build the blog -ailog build - -# Serve locally -ailog serve - -# Start MCP server -ailog mcp --port 8002 - -# Generate documentation -ailog doc readme --with-ai -ailog doc api --output ./docs -ailog doc structure --include-deps - -# Translate documents (requires Ollama) -ailog doc translate --input README.md --target-lang en -ailog doc translate --input docs/api.md --target-lang ja --model qwen2.5:latest - -# Clean build files -ailog clean -``` - -### AI Ecosystem Integration - -When integrated with ai.gpt, use natural language: -- "ブログ記事を書いて" → Triggers `log_ai_content` -- "記事一覧を見せて" → Triggers `log_list_posts` -- "ブログをビルドして" → Triggers `log_build_blog` - -### Documentation & Translation - -Generate comprehensive documentation and translate content: -- "READMEを生成して" → Triggers `log_generate_docs` -- "APIドキュメントを作成して" → Generates API documentation -- "プロジェクト構造を解析して" → Creates structure documentation -- "このファイルを英語に翻訳して" → Triggers `log_translate_document` -- "マークダウンを日本語に変換して" → Uses Ollama for translation - -## MCP Tools - -### ai.log Server (Port 8002) -- `create_blog_post` - Create new blog post -- `list_blog_posts` - List existing posts -- `build_blog` - Build static site -- `get_post_content` - Get post by slug -- `translate_document` ⭐ - Ollama-powered markdown translation -- `generate_documentation` ⭐ - Code analysis and documentation generation - -### ai.gpt Integration (Port 8001) -- `log_create_post` - Proxy to ai.log + error handling -- `log_list_posts` - Proxy to ai.log + formatting -- `log_build_blog` - Proxy to ai.log + AI features -- `log_get_post` - Proxy to ai.log + context -- `log_system_status` - Health check for ai.log -- `log_ai_content` ⭐ - AI memory → blog content generation -- `log_translate_document` 🌍 - Document translation via Ollama -- `log_generate_docs` 📚 - Documentation generation - -### Documentation Generation Tools -- `doc readme` - Generate README.md from project analysis -- `doc api` - Generate API documentation -- `doc structure` - Analyze and document project structure -- `doc changelog` - Generate changelog from git history -- `doc translate` 🌍 - Multi-language document translation - -### Translation Features -- **Language Support**: English, Japanese, Chinese, Korean, Spanish -- **Markdown Preservation**: Code blocks, links, images, tables maintained -- **Auto-Detection**: Automatically detects Japanese content -- **Ollama Integration**: Uses local AI models for privacy and cost-efficiency -- **Smart Processing**: Section-by-section translation with structure awareness - -## Configuration - -### ai.log Configuration -- Location: `~/.config/syui/ai/log/` -- Format: TOML configuration - -### ai.gpt Integration -- Configuration: `../config.json` -- Auto-detection: ai.log tools enabled when `./log/` directory exists -- System prompt: Automatically triggers blog tools for related queries - -## AI Integration Features - -### Memory-Driven Content Generation -- **Source**: ai.gpt memory system -- **Process**: Contextual memories → AI analysis → Blog content -- **Output**: Structured markdown with personal insights - -### Automatic Workflows -- Daily blog posts from accumulated memories -- Content enhancement and suggestions -- Related article recommendations -- Multi-language content generation - -## atproto Integration (Planned) - -### OAuth 2.0 Authentication -- Client metadata: `public/client-metadata.json` -- Comment system integration -- Data sovereignty: Users own their comments -- Collection storage in atproto - -### Comment System -- **ATProto Stream Monitoring**: Real-time Jetstream connection monitoring -- **Collection Tracking**: Monitors `ai.syui.log` collection for new comments -- **User Management**: Automatically adds commenting users to `ai.syui.log.user` collection -- **Comment Display**: Fetches and displays comments from registered users -- **OAuth Integration**: atproto account login via Cloudflare tunnel -- **Distributed Storage**: Comments stored in user-owned atproto collections - -## Build & Deploy - -### GitHub Actions -```yaml -# .github/workflows/gh-pages.yml -- name: Build ai.log - run: | - cd log - cargo build --release - ./target/release/ailog build -``` - -### Cloudflare Pages -- Static output: `./public/` -- Automatic deployment on main branch push -- AI content generation during build process - -## Development Status - -### ✅ Completed Features -- Project structure and Cargo.toml setup -- CLI interface (init, new, build, serve, clean, mcp, doc) -- 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 -- **MCP server integration (both layers)** -- **ai.gpt integration with 6 tools** -- **AI memory system connection** -- **📚 Documentation generation from code** -- **🔍 Rust project analysis and API extraction** -- **📝 README, API docs, and structure analysis** -- **🌍 Ollama-powered translation system** -- **🚀 Complete MCP integration with ai.gpt** -- **📄 Markdown-aware translation preserving structure** -- **💬 ATProto comment system with Jetstream monitoring** -- **🔄 Real-time comment collection and user management** -- **🔐 OAuth 2.1 integration with Cloudflare tunnel** -- **🤖 Ask AI feature with Ollama integration** -- **⚡ CORS resolution via OLLAMA_ORIGINS** -- **🔒 Authentication-gated AI chat** -- **📱 Top-page-only AI access pattern** -- Test blog with sample content and styling - -### 🚧 In Progress -- AI-powered content enhancement pipeline -- Advanced comment moderation system - -### 📋 Planned Features -- Advanced template customization -- Plugin system for extensibility -- Real-time comment system -- Multi-blog management -- VTuber integration (ai.verse connection) - -## Integration with ai Ecosystem - -### System Dependencies -- **ai.gpt**: Memory system, relationship tracking, AI provider -- **ai.card**: Future cross-system content sharing -- **ai.bot**: atproto posting and mention handling -- **ai.verse**: 3D world blog representation (future) - -### yui System Compliance -- **Uniqueness**: Each blog post tied to individual identity -- **Reality Reflection**: Personal memories → digital content -- **Irreversibility**: Published content maintains historical integrity - -## Getting Started - -### 1. Standalone Usage -```bash -git clone [repository] -cd log -cargo run -- init my-blog -cargo run -- new "First Post" -cargo run -- build -cargo run -- serve -``` - -### 2. AI Ecosystem Integration -```bash -# Start ai.log MCP server -cargo run -- mcp --port 8002 - -# In another terminal, start ai.gpt -cd ../ -# ai.gpt startup commands - -# Use Claude Code with natural language blog commands -``` - -## Documentation Generation Features - -### 📚 Automatic README Generation -```bash -# Generate README from project analysis -ailog doc readme --source ./src --with-ai - -# Output: Enhanced README.md with: -# - Project overview and metrics -# - Dependency analysis -# - Module structure -# - AI-generated insights -``` - -### 📖 API Documentation -```bash -# Generate comprehensive API docs -ailog doc api --source ./src --format markdown --output ./docs - -# Creates: -# - docs/api.md (main API overview) -# - docs/module_name.md (per-module documentation) -# - Function signatures and documentation -# - Struct/enum definitions -``` - -### 🏗️ Project Structure Analysis -```bash -# Analyze and document project structure -ailog doc structure --source . --include-deps - -# Generates: -# - Directory tree visualization -# - File distribution by language -# - Dependency graph analysis -# - Code metrics and statistics -``` - -### 📝 Git Changelog Generation -```bash -# Generate changelog from git history -ailog doc changelog --from v1.0.0 --explain-changes - -# Creates: -# - Structured changelog -# - Commit categorization -# - AI-enhanced change explanations -``` - -### 🤖 AI-Enhanced Documentation -When `--with-ai` is enabled: -- **Content Enhancement**: AI improves readability and adds insights -- **Context Awareness**: Leverages ai.gpt memory system -- **Smart Categorization**: Automatic organization of content -- **Technical Writing**: Professional documentation style - -## 🌍 Translation System - -### Ollama-Powered Translation - -ai.log includes a comprehensive translation system powered by Ollama AI models: - -```bash -# Basic translation -ailog doc translate --input README.md --target-lang en - -# Advanced translation with custom settings -ailog doc translate \ - --input docs/technical-guide.ja.md \ - --target-lang en \ - --source-lang ja \ - --output docs/technical-guide.en.md \ - --model qwen2.5:latest \ - --ollama-endpoint http://localhost:11434 -``` - -### Translation Features - -#### 📄 Markdown-Aware Processing -- **Code Block Preservation**: All code snippets remain untranslated -- **Link Maintenance**: URLs and link structures preserved -- **Image Handling**: Alt text can be translated while preserving image paths -- **Table Translation**: Table content translated while maintaining structure -- **Header Preservation**: Markdown headers translated with level maintenance - -#### 🎯 Smart Language Detection -- **Auto-Detection**: Automatically detects Japanese content using Unicode ranges -- **Manual Override**: Specify source language for precise control -- **Mixed Content**: Handles documents with multiple languages - -#### 🔧 Flexible Configuration -- **Model Selection**: Choose from available Ollama models -- **Custom Endpoints**: Use different Ollama instances -- **Output Control**: Auto-generate or specify output paths -- **Batch Processing**: Process multiple files efficiently +## 🌍 Translation Support ### Supported Languages -| Language | Code | Direction | Model Optimized | -|----------|------|-----------|-----------------| -| English | `en` | ↔️ | ✅ qwen2.5 | -| Japanese | `ja` | ↔️ | ✅ qwen2.5 | -| Chinese | `zh` | ↔️ | ✅ qwen2.5 | -| Korean | `ko` | ↔️ | ⚠️ Basic | -| Spanish | `es` | ↔️ | ⚠️ Basic | +| Language | Code | Status | Model | +|----------|------|--------|-------| +| English | `en` | ✅ Full | qwen2.5 | +| Japanese | `ja` | ✅ Full | qwen2.5 | +| Chinese | `zh` | ✅ Full | qwen2.5 | +| Korean | `ko` | ⚠️ Basic | qwen2.5 | +| Spanish | `es` | ⚠️ Basic | qwen2.5 | ### Translation Workflow -1. **Parse Document**: Analyze markdown structure and identify sections -2. **Preserve Code**: Isolate code blocks and technical content -3. **Translate Content**: Process text sections with Ollama AI -4. **Reconstruct**: Rebuild document maintaining original formatting -5. **Validate**: Ensure structural integrity and completeness +1. **Parse**: Analyze markdown structure +2. **Preserve**: Isolate code blocks and technical content +3. **Translate**: Process with Ollama AI +4. **Reconstruct**: Rebuild with original formatting +5. **Validate**: Ensure structural integrity -### Integration with ai.gpt +## 🎯 Use Cases -```python -# Via ai.gpt MCP tools -await log_translate_document( - input_file="README.ja.md", - target_lang="en", - model="qwen2.5:latest" -) +### Personal Blog +- **AI-Enhanced**: Automatic translations and AI insights +- **Distributed Comments**: ATProto-based social interaction +- **Mobile-First**: Responsive OAuth comment system + +### Technical Documentation +- **Code Analysis**: Automatic API documentation +- **Multi-language**: AI-powered translation +- **Structure Analysis**: Project overview generation + +### AI Ecosystem Integration +- **ai.gpt Connection**: Memory-driven content generation +- **MCP Integration**: Claude Code workflow support +- **Distributed Identity**: ATProto authentication + +## 🔍 Troubleshooting + +### Build Issues +```bash +# Check Rust version +rustc --version + +# Update dependencies +cargo update + +# Clean build +cargo clean && cargo build --release ``` -### Requirements +### Authentication Problems +```bash +# Reset authentication +ailog auth logout +ailog auth init -- **Ollama**: Install and run Ollama locally -- **Models**: Download supported models (qwen2.5:latest recommended) -- **Memory**: Sufficient RAM for model inference -- **Network**: For initial model download only +# Test API access +ailog stream test +``` -## Configuration Examples +### AI Generation Issues +```bash +# Check Ollama status +curl http://localhost:11434/api/tags -### Basic Blog Config +# Test with manual request +curl -X POST http://localhost:11434/api/generate \ + -d '{"model":"gemma3:4b","prompt":"Test","stream":false}' + +# Check CORS settings +# Ensure OLLAMA_ORIGINS includes your domain +``` + +### OAuth App Issues +```bash +# Rebuild OAuth assets +cd oauth +rm -rf dist/ +npm run build + +# Check environment variables +cat .env.production + +# Verify client-metadata.json +curl https://yourdomain.com/client-metadata.json +``` + +## 📚 Documentation + +### Core Concepts +- **Static Generation**: Rust-powered site building +- **JSON Index**: API-compatible blog data +- **ATProto Integration**: Distributed social features +- **AI Enhancement**: Automatic content generation + +### File Structure +- `config.toml`: Blog configuration (simplified collection setup) +- `content/posts/*.md`: Blog post sources +- `templates/*.html`: Tera template files +- `public/`: Generated static site + API (index.json) +- `oauth/dist/`: Built OAuth assets + +### Example config.toml ```toml -[blog] -title = "My AI Blog" -description = "Personal thoughts and AI insights" -base_url = "https://myblog.example.com" +[site] +title = "My Blog" +base_url = "https://myblog.com" + +[oauth] +admin = "did:plc:your-admin-did" +collection = "ai.myblog.log" # Single base collection [ai] -provider = "openai" -model = "gpt-4" -translation = true +enabled = true +auto_translate = true +comment_moderation = true +model = "gemma3:4b" +host = "https://ollama.syui.ai" ``` -### Advanced Integration -```json -// ../config.json (ai.gpt) -{ - "mcp": { - "servers": { - "ai_gpt": { - "endpoints": { - "log_ai_content": "/log_ai_content", - "log_create_post": "/log_create_post" - } - } - } - } -} -``` +## 🔗 ai.ai Ecosystem -## Troubleshooting +ai.log is part of the broader ai.ai ecosystem: -### MCP Connection Issues -- Ensure ai.log server is running: `cargo run -- mcp --port 8002` -- Check ai.gpt config includes log endpoints -- Verify `./log/` directory exists relative to ai.gpt +- **ai.gpt**: Memory system and AI integration +- **ai.card**: ATProto-based card game system +- **ai.bot**: Social media automation +- **ai.verse**: 3D virtual world integration +- **ai.shell**: AI-powered shell interface -### Build Failures -- Check Rust version: `rustc --version` -- Update dependencies: `cargo update` -- Clear cache: `cargo clean` +### yui System Compliance +- **Uniqueness**: Each blog tied to individual identity +- **Reality Reflection**: Personal memories → digital content +- **Irreversibility**: Published content maintains integrity -### AI Integration Problems -- Verify ai.gpt memory system is initialized -- Check AI provider configuration -- Ensure sufficient context in memory system - -## systemd - -```sh -$ sudo vim /usr/lib/systemd/system/ollama.service -[Service] -Environment="OLLAMA_ORIGINS=https://example.com" -``` - -```sh -# ファイルをsystemdディレクトリにコピー -sudo cp ./systemd/system/ailog-stream.service /etc/systemd/system/ -sudo cp ./systemd/system/cloudflared-log.service /etc/systemd/system/ - -# 権限設定 -sudo chmod 644 /etc/systemd/system/ailog-stream.service -sudo chmod 644 /etc/systemd/system/cloudflared-log.service - -# systemd設定reload -sudo systemctl daemon-reload - -# サービス有効化・開始 -sudo systemctl enable ailog-stream.service -sudo systemctl enable cloudflared-log.service - -sudo systemctl start ailog-stream.service -sudo systemctl start cloudflared-log.service - -# 状態確認 -sudo systemctl status ailog-stream.service -sudo systemctl status cloudflared-log.service - -# ログ確認 -journalctl -u ailog-stream.service -f -journalctl -u cloudflared-log.service -f - -設定のポイント: -- User=syui でユーザー権限で実行 -- Restart=always で異常終了時自動再起動 -- After=network.target でネットワーク起動後に実行 -- StandardOutput=journal でログをjournalctlで確認可能 -``` - -## License +## 📝 License © syui --- -**Part of the ai ecosystem**: ai.gpt, ai.card, ai.log, ai.bot, ai.verse, ai.shell +**Part of the ai ecosystem**: ai.gpt, ai.card, ai.log, ai.bot, ai.verse, ai.shell \ No newline at end of file diff --git a/ai_prompt.txt b/ai_prompt.txt new file mode 100644 index 0000000..ee2609e --- /dev/null +++ b/ai_prompt.txt @@ -0,0 +1 @@ +あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。 diff --git a/bin/ailog-generate.zsh b/bin/ailog-generate.zsh new file mode 100755 index 0000000..66458f0 --- /dev/null +++ b/bin/ailog-generate.zsh @@ -0,0 +1,173 @@ +#!/bin/zsh + +# Generate AI content for blog posts +# Usage: ./bin/ailog-generate.zsh [md-file] + +set -e + +# Load configuration +f=~/.config/syui/ai/bot/token.json + +# Default values +default_pds="bsky.social" +default_did=`cat $f|jq -r .did` +default_token=`cat $f|jq -r .accessJwt` +default_refresh=`cat $f|jq -r .refreshJwt` + +# Refresh token if needed +curl -sL -X POST -H "Content-Type: application/json" -H "Authorization: Bearer $default_refresh" https://$default_pds/xrpc/com.atproto.server.refreshSession >! $f +default_token=`cat $f|jq -r .accessJwt` + +# Set variables +admin_did=$default_did +admin_token=$default_token +ai_did="did:plc:4hqjfn7m6n5hno3doamuhgef" +ollama_host="https://ollama.syui.ai" +blog_host="https://syui.ai" +pds=$default_pds + +# Parse arguments +md_file=$1 + +# Function to generate content using Ollama +generate_ai_content() { + local content=$1 + local prompt_type=$2 + local model="gemma3:4b" + + case $prompt_type in + "translate") + prompt="Translate the following Japanese blog post to English. Keep the technical terms and code blocks intact:\n\n$content" + ;; + "comment") + prompt="Read this blog post and provide an insightful comment about it. Focus on the key points and add your perspective:\n\n$content" + ;; + esac + + response=$(curl -sL -X POST "$ollama_host/api/generate" \ + -H "Content-Type: application/json" \ + -d "{ + \"model\": \"$model\", + \"prompt\": \"$prompt\", + \"stream\": false, + \"options\": { + \"temperature\": 0.9, + \"top_p\": 0.9, + \"num_predict\": 500 + } + }") + + echo "$response" | jq -r '.response' +} + +# Function to put record to ATProto +put_record() { + local collection=$1 + local rkey=$2 + local record=$3 + + curl -sL -X POST "https://$pds/xrpc/com.atproto.repo.putRecord" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $admin_token" \ + -d "{ + \"repo\": \"$admin_did\", + \"collection\": \"$collection\", + \"rkey\": \"$rkey\", + \"record\": $record + }" +} + +# Function to process a single markdown file +process_md_file() { + local md_path=$1 + local filename=$(basename "$md_path" .md) + local content=$(cat "$md_path") + local post_url="$blog_host/posts/$filename" + local rkey=$filename + local now=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z") + + echo "Processing: $md_path" + echo "Post URL: $post_url" + + # Generate English translation + echo "Generating English translation..." + en_translation=$(generate_ai_content "$content" "translate") + + if [ -n "$en_translation" ]; then + lang_record="{ + \"\$type\": \"ai.syui.log.chat.lang\", + \"type\": \"en\", + \"body\": $(echo "$en_translation" | jq -Rs .), + \"url\": \"$post_url\", + \"createdAt\": \"$now\", + \"author\": { + \"did\": \"$ai_did\", + \"handle\": \"yui.syui.ai\", + \"displayName\": \"AI Translator\" + } + }" + + echo "Saving translation to ATProto..." + put_record "ai.syui.log.chat.lang" "$rkey" "$lang_record" + fi + + # Generate AI comment + echo "Generating AI comment..." + ai_comment=$(generate_ai_content "$content" "comment") + + if [ -n "$ai_comment" ]; then + comment_record="{ + \"\$type\": \"ai.syui.log.chat.comment\", + \"type\": \"push\", + \"body\": $(echo "$ai_comment" | jq -Rs .), + \"url\": \"$post_url\", + \"createdAt\": \"$now\", + \"author\": { + \"did\": \"$ai_did\", + \"handle\": \"yui.syui.ai\", + \"displayName\": \"AI Commenter\" + } + }" + + echo "Saving comment to ATProto..." + put_record "ai.syui.log.chat.comment" "$rkey" "$comment_record" + fi + + echo "Completed: $filename" + echo +} + +# Main logic +if [ -n "$md_file" ]; then + # Process specific file + if [ -f "$md_file" ]; then + process_md_file "$md_file" + else + echo "Error: File not found: $md_file" + exit 1 + fi +else + # Process all new posts + echo "Checking for posts without AI content..." + + # Get existing records + existing_langs=$(curl -sL "https://$pds/xrpc/com.atproto.repo.listRecords?repo=$admin_did&collection=ai.syui.log.chat.lang&limit=100" | jq -r '.records[]?.value.url' | sort | uniq) + + # Process each markdown file + for md in my-blog/content/posts/*.md; do + if [ -f "$md" ]; then + filename=$(basename "$md" .md) + post_url="$blog_host/posts/$filename" + + # Check if already processed + if echo "$existing_langs" | grep -q "$post_url"; then + echo "Skip (already processed): $filename" + else + process_md_file "$md" + sleep 2 # Rate limiting + fi + fi + done +fi + +echo "All done!" \ No newline at end of file diff --git a/my-blog/config.toml b/my-blog/config.toml index 70df872..f07f969 100644 --- a/my-blog/config.toml +++ b/my-blog/config.toml @@ -18,14 +18,12 @@ ask_ai = true provider = "ollama" model = "gemma3:4b" host = "https://ollama.syui.ai" -system_prompt = "you are a helpful ai assistant trained on this blog's content. you can answer questions about the articles, provide insights, and help users understand the topics discussed." +system_prompt = "あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。" ai_did = "did:plc:4hqjfn7m6n5hno3doamuhgef" [oauth] json = "client-metadata.json" redirect = "oauth/callback" admin = "did:plc:uqzpqmrjnptsxezjx4xuh2mn" -collection_comment = "ai.syui.log" -collection_user = "ai.syui.log.user" -collection_chat = "ai.syui.log.chat" +collection = "ai.syui.log" bsky_api = "https://public.api.bsky.app" diff --git a/my-blog/layouts/_default/index.json b/my-blog/layouts/_default/index.json new file mode 100644 index 0000000..a18a376 --- /dev/null +++ b/my-blog/layouts/_default/index.json @@ -0,0 +1,7 @@ +{{ $dateFormat := default "Mon Jan 2, 2006" (index .Site.Params "date_format") }} +{{ $utcFormat := "2006-01-02T15:04:05Z07:00" }} +{{- $.Scratch.Add "index" slice -}} +{{- range .Site.RegularPages -}} + {{- $.Scratch.Add "index" (dict "title" .Title "tags" .Params.tags "description" .Description "categories" .Params.categories "contents" .Plain "href" .Permalink "utc_time" (.Date.Format $utcFormat) "formated_time" (.Date.Format $dateFormat)) -}} +{{- end -}} +{{- $.Scratch.Get "index" | jsonify -}} diff --git a/my-blog/static/index.html b/my-blog/static/index.html index de0aef9..41ee2db 100644 --- a/my-blog/static/index.html +++ b/my-blog/static/index.html @@ -1,3 +1,3 @@ <!-- OAuth Comment System - Load globally for session management --> -<script type="module" crossorigin src="/assets/comment-atproto-DaSMjKIj.js"></script> +<script type="module" crossorigin src="/assets/comment-atproto-G86WWmu8.js"></script> <link rel="stylesheet" crossorigin href="/assets/comment-atproto-FS0uZjXB.css"> \ No newline at end of file diff --git a/my-blog/templates/oauth-assets.html b/my-blog/templates/oauth-assets.html index de0aef9..41ee2db 100644 --- a/my-blog/templates/oauth-assets.html +++ b/my-blog/templates/oauth-assets.html @@ -1,3 +1,3 @@ <!-- OAuth Comment System - Load globally for session management --> -<script type="module" crossorigin src="/assets/comment-atproto-DaSMjKIj.js"></script> +<script type="module" crossorigin src="/assets/comment-atproto-G86WWmu8.js"></script> <link rel="stylesheet" crossorigin href="/assets/comment-atproto-FS0uZjXB.css"> \ No newline at end of file diff --git a/oauth/.env.production b/oauth/.env.production index 6c63df9..fbe2758 100644 --- a/oauth/.env.production +++ b/oauth/.env.production @@ -4,15 +4,9 @@ VITE_OAUTH_CLIENT_ID=https://syui.ai/client-metadata.json VITE_OAUTH_REDIRECT_URI=https://syui.ai/oauth/callback VITE_ADMIN_DID=did:plc:uqzpqmrjnptsxezjx4xuh2mn -# Collection names for OAuth app -VITE_COLLECTION_COMMENT=ai.syui.log -VITE_COLLECTION_USER=ai.syui.log.user -VITE_COLLECTION_CHAT=ai.syui.log.chat - -# Collection names for ailog (backward compatibility) -AILOG_COLLECTION_COMMENT=ai.syui.log -AILOG_COLLECTION_USER=ai.syui.log.user -AILOG_COLLECTION_CHAT=ai.syui.log.chat +# Base collection for OAuth app and ailog (all others are derived) +VITE_OAUTH_COLLECTION=ai.syui.log +# [user, chat, chat.lang, chat.comment] # AI Configuration VITE_AI_ENABLED=true diff --git a/oauth/src/App.tsx b/oauth/src/App.tsx index ea97591..fb4c80b 100644 --- a/oauth/src/App.tsx +++ b/oauth/src/App.tsx @@ -3,7 +3,7 @@ import { OAuthCallback } from './components/OAuthCallback'; import { AIChat } from './components/AIChat'; import { authService, User } from './services/auth'; import { atprotoOAuthService } from './services/atproto-oauth'; -import { appConfig } from './config/app'; +import { appConfig, getCollectionNames } from './config/app'; import './App.css'; function App() { @@ -46,8 +46,10 @@ function App() { const [isPostingUserList, setIsPostingUserList] = useState(false); const [userListRecords, setUserListRecords] = useState<any[]>([]); const [showJsonFor, setShowJsonFor] = useState<string | null>(null); - const [activeTab, setActiveTab] = useState<'comments' | 'ai-chat'>('comments'); + const [activeTab, setActiveTab] = useState<'comments' | 'ai-chat' | 'lang-en' | 'ai-comment'>('comments'); const [aiChatHistory, setAiChatHistory] = useState<any[]>([]); + const [langEnRecords, setLangEnRecords] = useState<any[]>([]); + const [aiCommentRecords, setAiCommentRecords] = useState<any[]>([]); useEffect(() => { // Setup Jetstream WebSocket for real-time comments (optional) @@ -55,17 +57,18 @@ function App() { try { const ws = new WebSocket('wss://jetstream2.us-east.bsky.network/subscribe'); + const collections = getCollectionNames(appConfig.collections.base); ws.onopen = () => { console.log('Jetstream connected'); ws.send(JSON.stringify({ - wantedCollections: [appConfig.collections.comment] + wantedCollections: [collections.comment] })); }; ws.onmessage = (event) => { try { const data = JSON.parse(event.data); - if (data.collection === appConfig.collections.comment && data.commit?.operation === 'create') { + if (data.collection === collections.comment && data.commit?.operation === 'create') { console.log('New comment detected via Jetstream:', data); // Optionally reload comments // loadAllComments(window.location.href); @@ -190,6 +193,9 @@ function App() { }; checkAuth(); + + // Load AI generated content (public) + loadAIGeneratedContent(); return () => { window.removeEventListener('popstate', handlePopState); @@ -274,6 +280,45 @@ function App() { } }; + // Load AI generated content from admin DID + const loadAIGeneratedContent = async () => { + try { + const adminDid = appConfig.adminDid; + const bskyApi = appConfig.bskyPublicApi || 'https://public.api.bsky.app'; + const collections = getCollectionNames(appConfig.collections.base); + + // Load lang:en records + const langResponse = await fetch(`${bskyApi}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(adminDid)}&collection=${encodeURIComponent(collections.chatLang)}&limit=100`); + if (langResponse.ok) { + const langData = await langResponse.json(); + const langRecords = langData.records || []; + + // Filter by current page URL if on post page + const filteredLangRecords = appConfig.rkey + ? langRecords.filter(record => record.value.url === window.location.href) + : langRecords.slice(0, 3); // Top page: latest 3 + + setLangEnRecords(filteredLangRecords); + } + + // Load AI comment records + const commentResponse = await fetch(`${bskyApi}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(adminDid)}&collection=${encodeURIComponent(collections.chatComment)}&limit=100`); + if (commentResponse.ok) { + const commentData = await commentResponse.json(); + const commentRecords = commentData.records || []; + + // Filter by current page URL if on post page + const filteredCommentRecords = appConfig.rkey + ? commentRecords.filter(record => record.value.url === window.location.href) + : commentRecords.slice(0, 3); // Top page: latest 3 + + setAiCommentRecords(filteredCommentRecords); + } + } catch (err) { + console.error('Failed to load AI generated content:', err); + } + }; + const loadUserComments = async (did: string) => { try { console.log('Loading comments for DID:', did); @@ -454,7 +499,8 @@ function App() { console.log(`Fetching comments from user: ${user.handle} (${user.did}) at ${user.pds}`); // Public API使用(認証不要) - const response = await fetch(`${user.pds}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(user.did)}&collection=${encodeURIComponent(appConfig.collections.comment)}&limit=100`); + const collections = getCollectionNames(appConfig.collections.base); + const response = await fetch(`${user.pds}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(user.did)}&collection=${encodeURIComponent(collections.comment)}&limit=100`); if (!response.ok) { console.warn(`Failed to fetch from ${user.handle} (${response.status}): ${response.statusText}`); @@ -1043,6 +1089,18 @@ function App() { AI Chat History ({aiChatHistory.length}) </button> )} + <button + className={`tab-button ${activeTab === 'lang-en' ? 'active' : ''}`} + onClick={() => setActiveTab('lang-en')} + > + Lang: EN ({langEnRecords.length}) + </button> + <button + className={`tab-button ${activeTab === 'ai-comment' ? 'active' : ''}`} + onClick={() => setActiveTab('ai-comment')} + > + AI Comment ({aiCommentRecords.length}) + </button> </div> {/* Comments List */} @@ -1118,7 +1176,7 @@ function App() { </div> <div className="comment-meta"> {record.value.url && ( - <small><a href={record.value.url} target="_blank" rel="noopener noreferrer">{record.value.url}</a></small> + <small><a href={record.value.url}>{record.value.url}</a></small> )} </div> @@ -1204,7 +1262,7 @@ function App() { </div> <div className="comment-meta"> {record.value.url && ( - <small><a href={record.value.url} target="_blank" rel="noopener noreferrer">{record.value.url}</a></small> + <small><a href={record.value.url}>{record.value.url}</a></small> )} </div> @@ -1223,6 +1281,88 @@ function App() { </div> )} + {/* Lang: EN List */} + {activeTab === 'lang-en' && ( + <div className="lang-en-list"> + {langEnRecords.length === 0 ? ( + <p className="no-content">No English translations yet</p> + ) : ( + langEnRecords.map((record, index) => ( + <div key={index} className="lang-item"> + <div className="lang-header"> + <img + src={record.value.author?.avatar || generatePlaceholderAvatar(record.value.author?.handle || 'AI')} + alt="AI Avatar" + className="comment-avatar" + /> + <div className="comment-author-info"> + <span className="comment-author"> + {record.value.author?.displayName || 'AI Translator'} + </span> + <span className="comment-handle"> + @{record.value.author?.handle || 'ai'} + </span> + </div> + <span className="comment-date"> + {new Date(record.value.createdAt).toLocaleString()} + </span> + </div> + <div className="lang-content"> + <div className="lang-type">Type: {record.value.type || 'en'}</div> + <div className="lang-body">{record.value.body}</div> + </div> + <div className="comment-meta"> + {record.value.url && ( + <small><a href={record.value.url}>{record.value.url}</a></small> + )} + </div> + </div> + )) + )} + </div> + )} + + {/* AI Comment List */} + {activeTab === 'ai-comment' && ( + <div className="ai-comment-list"> + {aiCommentRecords.length === 0 ? ( + <p className="no-content">No AI comments yet</p> + ) : ( + aiCommentRecords.map((record, index) => ( + <div key={index} className="ai-comment-item"> + <div className="ai-comment-header"> + <img + src={record.value.author?.avatar || generatePlaceholderAvatar(record.value.author?.handle || 'AI')} + alt="AI Avatar" + className="comment-avatar" + /> + <div className="comment-author-info"> + <span className="comment-author"> + {record.value.author?.displayName || 'AI Commenter'} + </span> + <span className="comment-handle"> + @{record.value.author?.handle || 'ai'} + </span> + </div> + <span className="comment-date"> + {new Date(record.value.createdAt).toLocaleString()} + </span> + </div> + <div className="ai-comment-content"> + <div className="ai-comment-type">Type: {record.value.type || 'comment'}</div> + <div className="ai-comment-body">{record.value.body}</div> + </div> + <div className="comment-meta"> + {record.value.url && ( + <small><a href={record.value.url}>{record.value.url}</a></small> + )} + </div> + </div> + )) + )} + </div> + )} + {/* Comment Form - Only show on post pages */} {user && appConfig.rkey && ( <div className="comment-form"> diff --git a/oauth/src/config/app.ts b/oauth/src/config/app.ts index 5867468..5779f30 100644 --- a/oauth/src/config/app.ts +++ b/oauth/src/config/app.ts @@ -2,9 +2,7 @@ export interface AppConfig { adminDid: string; collections: { - comment: string; - user: string; - chat: string; + base: string; // Base collection like "ai.syui.log" }; host: string; rkey?: string; // Current post rkey if on post page @@ -16,10 +14,21 @@ export interface AppConfig { bskyPublicApi: string; } +// Collection name builders (similar to Rust implementation) +export function getCollectionNames(base: string) { + return { + comment: base, + user: `${base}.user`, + chat: `${base}.chat`, + chatLang: `${base}.chat.lang`, + chatComment: `${base}.chat.comment`, + }; +} + // Generate collection names from host // Format: ${reg}.${name}.${sub} // Example: log.syui.ai -> ai.syui.log -function generateCollectionNames(host: string): { comment: string; user: string; chat: string } { +function generateBaseCollectionFromHost(host: string): string { try { // Remove protocol if present const cleanHost = host.replace(/^https?:\/\//, ''); @@ -34,21 +43,11 @@ function generateCollectionNames(host: string): { comment: string; user: string; // Reverse the parts for collection naming // log.syui.ai -> ai.syui.log const reversedParts = parts.reverse(); - const collectionBase = reversedParts.join('.'); - - return { - comment: collectionBase, - user: `${collectionBase}.user`, - chat: `${collectionBase}.chat` - }; + return reversedParts.join('.'); } catch (error) { - console.warn('Failed to generate collection names from host:', host, error); - // Fallback to default collections - return { - comment: 'ai.syui.log', - user: 'ai.syui.log.user', - chat: 'ai.syui.log.chat' - }; + console.warn('Failed to generate collection base from host:', host, error); + // Fallback to default + return 'ai.syui.log'; } } @@ -66,11 +65,9 @@ export function getAppConfig(): AppConfig { const adminDid = import.meta.env.VITE_ADMIN_DID || 'did:plc:uqzpqmrjnptsxezjx4xuh2mn'; // Priority: Environment variables > Auto-generated from host - const autoGeneratedCollections = generateCollectionNames(host); + const autoGeneratedBase = generateBaseCollectionFromHost(host); const collections = { - comment: import.meta.env.VITE_COLLECTION_COMMENT || autoGeneratedCollections.comment, - user: import.meta.env.VITE_COLLECTION_USER || autoGeneratedCollections.user, - chat: import.meta.env.VITE_COLLECTION_CHAT || autoGeneratedCollections.chat, + base: import.meta.env.VITE_OAUTH_COLLECTION || autoGeneratedBase, }; const rkey = extractRkeyFromUrl(); diff --git a/src/commands/auth.rs b/src/commands/auth.rs index ef0e690..b0d3183 100644 --- a/src/commands/auth.rs +++ b/src/commands/auth.rs @@ -28,8 +28,31 @@ pub struct JetstreamConfig { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CollectionConfig { - pub comment: String, - pub user: String, + pub base: String, // Base collection name like "ai.syui.log" +} + +impl CollectionConfig { + // Collection name builders + pub fn comment(&self) -> String { + self.base.clone() + } + + pub fn user(&self) -> String { + format!("{}.user", self.base) + } + + #[allow(dead_code)] + pub fn chat(&self) -> String { + format!("{}.chat", self.base) + } + + pub fn chat_lang(&self) -> String { + format!("{}.chat.lang", self.base) + } + + pub fn chat_comment(&self) -> String { + format!("{}.chat.comment", self.base) + } } impl Default for AuthConfig { @@ -47,8 +70,7 @@ impl Default for AuthConfig { collections: vec!["ai.syui.log".to_string()], }, collections: CollectionConfig { - comment: "ai.syui.log".to_string(), - user: "ai.syui.log.user".to_string(), + base: "ai.syui.log".to_string(), }, } } @@ -220,11 +242,50 @@ pub fn load_config() -> Result<AuthConfig> { } let config_json = fs::read_to_string(&config_path)?; - let mut config: AuthConfig = serde_json::from_str(&config_json)?; - // Update collection configuration + // Try to load as new format first, then migrate if needed + match serde_json::from_str::<AuthConfig>(&config_json) { + Ok(mut config) => { + // Update collection configuration + update_config_collections(&mut config); + Ok(config) + } + Err(e) => { + println!("{}", format!("Parse error: {}, attempting migration...", e).yellow()); + // Try to migrate from old format + migrate_config_if_needed(&config_path, &config_json) + } + } +} + +fn migrate_config_if_needed(config_path: &std::path::Path, config_json: &str) -> Result<AuthConfig> { + // Try to parse as old format and migrate to new simple format + let mut old_config: serde_json::Value = serde_json::from_str(config_json)?; + + // Migrate old collections structure to new base-only structure + if let Some(collections) = old_config.get_mut("collections") { + // Extract base collection name from comment field or use default + let base_collection = collections.get("comment") + .and_then(|v| v.as_str()) + .unwrap_or("ai.syui.log") + .to_string(); + + // Replace entire collections structure with new format + old_config["collections"] = serde_json::json!({ + "base": base_collection + }); + } + + // Save migrated config + let migrated_config_json = serde_json::to_string_pretty(&old_config)?; + fs::write(config_path, migrated_config_json)?; + + // Parse as new format + let mut config: AuthConfig = serde_json::from_value(old_config)?; update_config_collections(&mut config); + println!("{}", "✅ Configuration migrated to new simplified format".green()); + Ok(config) } @@ -259,7 +320,7 @@ async fn test_api_access_with_auth(config: &AuthConfig) -> Result<()> { let url = format!("{}/xrpc/com.atproto.repo.listRecords?repo={}&collection={}&limit=1", config.admin.pds, urlencoding::encode(&config.admin.did), - urlencoding::encode(&config.collections.comment)); + urlencoding::encode(&config.collections.comment())); let response = client .get(&url) @@ -311,23 +372,14 @@ fn save_config(config: &AuthConfig) -> Result<()> { Ok(()) } -// Generate collection names from admin DID or environment +// Generate collection config from environment fn generate_collection_config() -> CollectionConfig { - // Check environment variables first - if let (Ok(comment), Ok(user)) = ( - std::env::var("AILOG_COLLECTION_COMMENT"), - std::env::var("AILOG_COLLECTION_USER") - ) { - return CollectionConfig { - comment, - user, - }; - } + // Use VITE_OAUTH_COLLECTION for unified configuration + let base = std::env::var("VITE_OAUTH_COLLECTION") + .unwrap_or_else(|_| "ai.syui.log".to_string()); - // Default collections CollectionConfig { - comment: "ai.syui.log".to_string(), - user: "ai.syui.log.user".to_string(), + base, } } @@ -335,5 +387,5 @@ fn generate_collection_config() -> CollectionConfig { pub fn update_config_collections(config: &mut AuthConfig) { config.collections = generate_collection_config(); // Also update jetstream collections to monitor the comment collection - config.jetstream.collections = vec![config.collections.comment.clone()]; + config.jetstream.collections = vec![config.collections.comment()]; } \ No newline at end of file diff --git a/src/commands/oauth.rs b/src/commands/oauth.rs index 6b12ad5..6f02ae0 100644 --- a/src/commands/oauth.rs +++ b/src/commands/oauth.rs @@ -45,18 +45,10 @@ pub async fn build(project_dir: PathBuf) -> Result<()> { .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("No admin DID found in [oauth] section"))?; - let collection_comment = oauth_config.get("collection_comment") + let collection_base = oauth_config.get("collection") .and_then(|v| v.as_str()) .unwrap_or("ai.syui.log"); - let collection_user = oauth_config.get("collection_user") - .and_then(|v| v.as_str()) - .unwrap_or("ai.syui.log.user"); - - let collection_chat = oauth_config.get("collection_chat") - .and_then(|v| v.as_str()) - .unwrap_or("ai.syui.log.chat"); - // Extract AI config if present let ai_config = config.get("ai") .and_then(|v| v.as_table()); @@ -109,15 +101,8 @@ VITE_OAUTH_CLIENT_ID={}/{} VITE_OAUTH_REDIRECT_URI={}/{} VITE_ADMIN_DID={} -# Collection names for OAuth app -VITE_COLLECTION_COMMENT={} -VITE_COLLECTION_USER={} -VITE_COLLECTION_CHAT={} - -# Collection names for ailog (backward compatibility) -AILOG_COLLECTION_COMMENT={} -AILOG_COLLECTION_USER={} -AILOG_COLLECTION_CHAT={} +# Base collection for OAuth app and ailog (all others are derived) +VITE_OAUTH_COLLECTION={} # AI Configuration VITE_AI_ENABLED={} @@ -135,12 +120,7 @@ VITE_BSKY_PUBLIC_API={} base_url, client_id_path, base_url, redirect_path, admin_did, - collection_comment, - collection_user, - collection_chat, - collection_comment, - collection_user, - collection_chat, + collection_base, ai_enabled, ai_ask_ai, ai_provider, diff --git a/src/commands/stream.rs b/src/commands/stream.rs index 76d7d41..2f2ecbd 100644 --- a/src/commands/stream.rs +++ b/src/commands/stream.rs @@ -10,18 +10,58 @@ use std::process::{Command, Stdio}; use tokio::time::{sleep, Duration, interval}; use tokio_tungstenite::{connect_async, tungstenite::Message}; use toml; +use reqwest; use super::auth::{load_config, load_config_with_refresh, AuthConfig}; +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +struct BlogPost { + title: String, + href: String, + #[serde(rename = "formated_time")] + #[allow(dead_code)] + date: String, + #[allow(dead_code)] + tags: Vec<String>, + #[allow(dead_code)] + contents: String, +} + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] +struct BlogIndex { + #[allow(dead_code)] + posts: Vec<BlogPost>, +} + +#[derive(Debug, Serialize)] +struct OllamaRequest { + model: String, + prompt: String, + stream: bool, + options: OllamaOptions, +} + +#[derive(Debug, Serialize)] +struct OllamaOptions { + temperature: f32, + top_p: f32, + num_predict: i32, +} + +#[derive(Debug, Deserialize)] +struct OllamaResponse { + response: String, +} + // Load collection config with priority: env vars > project config.toml > defaults fn load_collection_config(project_dir: Option<&Path>) -> Result<(String, String)> { // 1. Check environment variables first (highest priority) - if let (Ok(comment), Ok(user)) = ( - std::env::var("AILOG_COLLECTION_COMMENT"), - std::env::var("AILOG_COLLECTION_USER") - ) { + if let Ok(base_collection) = std::env::var("VITE_OAUTH_COLLECTION") { println!("{}", "📂 Using collection config from environment variables".cyan()); - return Ok((comment, user)); + let collection_user = format!("{}.user", base_collection); + return Ok((base_collection, collection_user)); } // 2. Try to load from project config.toml (second priority) @@ -60,17 +100,16 @@ fn load_collection_config_from_project(project_dir: &Path) -> Result<(String, St .and_then(|v| v.as_table()) .ok_or_else(|| anyhow::anyhow!("No [oauth] section found in config.toml"))?; - let collection_comment = oauth_config.get("collection_comment") + // Use new simplified collection structure (base collection) + let collection_base = oauth_config.get("collection") .and_then(|v| v.as_str()) .unwrap_or("ai.syui.log") .to_string(); - let collection_user = oauth_config.get("collection_user") - .and_then(|v| v.as_str()) - .unwrap_or("ai.syui.log.user") - .to_string(); + // Derive user collection from base + let collection_user = format!("{}.user", collection_base); - Ok((collection_comment, collection_user)) + Ok((collection_base, collection_user)) } #[derive(Debug, Serialize, Deserialize)] @@ -118,15 +157,14 @@ fn get_pid_file() -> Result<PathBuf> { Ok(pid_dir.join("stream.pid")) } -pub async fn start(project_dir: Option<PathBuf>, daemon: bool) -> Result<()> { +pub async fn start(project_dir: Option<PathBuf>, daemon: bool, ai_generate: bool) -> Result<()> { let mut config = load_config_with_refresh().await?; // Load collection config with priority: env vars > project config > defaults - let (collection_comment, collection_user) = load_collection_config(project_dir.as_deref())?; + let (collection_comment, _collection_user) = load_collection_config(project_dir.as_deref())?; // Update config with loaded collections - config.collections.comment = collection_comment.clone(); - config.collections.user = collection_user; + config.collections.base = collection_comment.clone(); config.jetstream.collections = vec![collection_comment]; let pid_file = get_pid_file()?; @@ -151,6 +189,11 @@ pub async fn start(project_dir: Option<PathBuf>, daemon: bool) -> Result<()> { args.push(project_path.to_string_lossy().to_string()); } + // Add ai_generate flag if enabled + if ai_generate { + args.push("--ai-generate".to_string()); + } + let child = Command::new(current_exe) .args(&args) .stdin(Stdio::null()) @@ -192,6 +235,19 @@ pub async fn start(project_dir: Option<PathBuf>, daemon: bool) -> Result<()> { let max_reconnect_attempts = 10; let mut config = config; // Make config mutable for token refresh + // Start AI generation monitor if enabled + if ai_generate { + let ai_config = config.clone(); + tokio::spawn(async move { + loop { + if let Err(e) = run_ai_generation_monitor(&ai_config).await { + println!("{}", format!("❌ AI generation monitor error: {}", e).red()); + sleep(Duration::from_secs(60)).await; // Wait 1 minute before retry + } + } + }); + } + loop { match run_monitor(&mut config).await { Ok(_) => { @@ -344,7 +400,7 @@ async fn handle_message(text: &str, config: &mut AuthConfig) -> Result<()> { if let (Some(collection), Some(commit), Some(did)) = (&message.collection, &message.commit, &message.did) { - if collection == &config.collections.comment && commit.operation.as_deref() == Some("create") { + if collection == &config.collections.comment() && commit.operation.as_deref() == Some("create") { let unknown_uri = "unknown".to_string(); let uri = commit.uri.as_ref().unwrap_or(&unknown_uri); @@ -438,7 +494,7 @@ async fn get_current_user_list(config: &mut AuthConfig) -> Result<Vec<UserRecord let url = format!("{}/xrpc/com.atproto.repo.listRecords?repo={}&collection={}&limit=10", config.admin.pds, urlencoding::encode(&config.admin.did), - urlencoding::encode(&config.collections.user)); + urlencoding::encode(&config.collections.user())); let response = client .get(&url) @@ -501,7 +557,7 @@ async fn post_user_list(config: &mut AuthConfig, users: &[UserRecord], metadata: let rkey = format!("{}-{}", short_did, now.format("%Y-%m-%dT%H-%M-%S-%3fZ").to_string().replace(".", "-")); let record = UserListRecord { - record_type: config.collections.user.clone(), + record_type: config.collections.user(), users: users.to_vec(), created_at: now.to_rfc3339(), updated_by: UserInfo { @@ -515,7 +571,7 @@ async fn post_user_list(config: &mut AuthConfig, users: &[UserRecord], metadata: let request_body = json!({ "repo": config.admin.did, - "collection": config.collections.user, + "collection": config.collections.user(), "rkey": rkey, "record": record }); @@ -759,7 +815,7 @@ async fn get_recent_comments(config: &mut AuthConfig) -> Result<Vec<Value>> { let url = format!("{}/xrpc/com.atproto.repo.listRecords?repo={}&collection={}&limit=20", config.admin.pds, urlencoding::encode(&config.admin.did), - urlencoding::encode(&config.collections.comment)); + urlencoding::encode(&config.collections.comment())); if std::env::var("AILOG_DEBUG").is_ok() { println!("{}", format!("🌐 API Request URL: {}", url).yellow()); @@ -840,7 +896,7 @@ pub async fn test_api() -> Result<()> { println!("{}", format!("✅ Successfully retrieved {} comments", comments.len()).green()); if comments.is_empty() { - println!("{}", format!("ℹ️ No comments found in {} collection", config.collections.comment).blue()); + println!("{}", format!("ℹ️ No comments found in {} collection", config.collections.comment()).blue()); println!("💡 Try posting a comment first using the web interface"); } else { println!("{}", "📝 Comment details:".cyan()); @@ -871,5 +927,273 @@ pub async fn test_api() -> Result<()> { } } + Ok(()) +} + +// AI content generation functions +async fn generate_ai_content(content: &str, prompt_type: &str, ollama_host: &str) -> Result<String> { + let model = "gemma3:4b"; + + let prompt = match prompt_type { + "translate" => format!("Translate the following Japanese blog post to English. Keep the technical terms and code blocks intact:\n\n{}", content), + "comment" => format!("Read this blog post and provide an insightful comment about it. Focus on the key points and add your perspective:\n\n{}", content), + _ => return Err(anyhow::anyhow!("Unknown prompt type: {}", prompt_type)), + }; + + let request = OllamaRequest { + model: model.to_string(), + prompt, + stream: false, + options: OllamaOptions { + temperature: 0.9, + top_p: 0.9, + num_predict: 500, + }, + }; + + let client = reqwest::Client::new(); + + // Try localhost first (for same-server deployment) + let localhost_url = "http://localhost:11434/api/generate"; + match client.post(localhost_url).json(&request).send().await { + Ok(response) if response.status().is_success() => { + let ollama_response: OllamaResponse = response.json().await?; + println!("{}", "✅ Used localhost Ollama".green()); + return Ok(ollama_response.response); + } + _ => { + println!("{}", "⚠️ Localhost Ollama not available, trying remote...".yellow()); + } + } + + // Fallback to remote host + let remote_url = format!("{}/api/generate", ollama_host); + let response = client.post(&remote_url).json(&request).send().await?; + + if !response.status().is_success() { + return Err(anyhow::anyhow!("Ollama API request failed: {}", response.status())); + } + + let ollama_response: OllamaResponse = response.json().await?; + println!("{}", "✅ Used remote Ollama".green()); + Ok(ollama_response.response) +} + +async fn run_ai_generation_monitor(config: &AuthConfig) -> Result<()> { + let blog_host = "https://syui.ai"; // TODO: Load from config + let ollama_host = "https://ollama.syui.ai"; // TODO: Load from config + let ai_did = "did:plc:4hqjfn7m6n5hno3doamuhgef"; // TODO: Load from config + + println!("{}", "🤖 Starting AI content generation monitor...".cyan()); + println!("📡 Blog host: {}", blog_host); + println!("🧠 Ollama host: {}", ollama_host); + println!("🤖 AI DID: {}", ai_did); + println!(); + + let mut interval = interval(Duration::from_secs(300)); // Check every 5 minutes + let client = reqwest::Client::new(); + + loop { + interval.tick().await; + + println!("{}", "🔍 Checking for new blog posts...".blue()); + + match check_and_process_new_posts(&client, config, blog_host, ollama_host, ai_did).await { + Ok(count) => { + if count > 0 { + println!("{}", format!("✅ Processed {} new posts", count).green()); + } else { + println!("{}", "ℹ️ No new posts found".blue()); + } + } + Err(e) => { + println!("{}", format!("❌ Error processing posts: {}", e).red()); + } + } + + println!("{}", "⏰ Waiting for next check...".cyan()); + } +} + +async fn check_and_process_new_posts( + client: &reqwest::Client, + config: &AuthConfig, + blog_host: &str, + ollama_host: &str, + ai_did: &str, +) -> Result<usize> { + // Fetch blog index + let index_url = format!("{}/index.json", blog_host); + let response = client.get(&index_url).send().await?; + + if !response.status().is_success() { + return Err(anyhow::anyhow!("Failed to fetch blog index: {}", response.status())); + } + + let blog_posts: Vec<BlogPost> = response.json().await?; + println!("{}", format!("📄 Found {} posts in blog index", blog_posts.len()).cyan()); + + // Get existing AI generated content from collections + let existing_lang_records = get_existing_records(config, &config.collections.chat_lang()).await?; + let existing_comment_records = get_existing_records(config, &config.collections.chat_comment()).await?; + + let mut processed_count = 0; + + for post in blog_posts { + let post_slug = extract_slug_from_url(&post.href); + + // Check if translation already exists + let translation_exists = existing_lang_records.iter().any(|record| { + record.get("value") + .and_then(|v| v.get("post_slug")) + .and_then(|s| s.as_str()) + == Some(&post_slug) + }); + + // Check if comment already exists + let comment_exists = existing_comment_records.iter().any(|record| { + record.get("value") + .and_then(|v| v.get("post_slug")) + .and_then(|s| s.as_str()) + == Some(&post_slug) + }); + + // Generate translation if not exists + if !translation_exists { + match generate_and_store_translation(client, config, &post, ollama_host, ai_did).await { + Ok(_) => { + println!("{}", format!("✅ Generated translation for: {}", post.title).green()); + processed_count += 1; + } + Err(e) => { + println!("{}", format!("❌ Failed to generate translation for {}: {}", post.title, e).red()); + } + } + } + + // Generate comment if not exists + if !comment_exists { + match generate_and_store_comment(client, config, &post, ollama_host, ai_did).await { + Ok(_) => { + println!("{}", format!("✅ Generated comment for: {}", post.title).green()); + processed_count += 1; + } + Err(e) => { + println!("{}", format!("❌ Failed to generate comment for {}: {}", post.title, e).red()); + } + } + } + } + + Ok(processed_count) +} + +async fn get_existing_records(config: &AuthConfig, collection: &str) -> Result<Vec<serde_json::Value>> { + let client = reqwest::Client::new(); + let url = format!("{}/xrpc/com.atproto.repo.listRecords?repo={}&collection={}&limit=100", + config.admin.pds, + urlencoding::encode(&config.admin.did), + urlencoding::encode(collection)); + + let response = client + .get(&url) + .header("Authorization", format!("Bearer {}", config.admin.access_jwt)) + .send() + .await?; + + if !response.status().is_success() { + return Ok(Vec::new()); // Return empty if collection doesn't exist yet + } + + let list_response: serde_json::Value = response.json().await?; + let records = list_response["records"].as_array().unwrap_or(&Vec::new()).clone(); + + Ok(records) +} + +fn extract_slug_from_url(url: &str) -> String { + // Extract slug from URL like "/posts/2025-06-06-ailog.html" + url.split('/') + .last() + .unwrap_or("") + .trim_end_matches(".html") + .to_string() +} + +async fn generate_and_store_translation( + client: &reqwest::Client, + config: &AuthConfig, + post: &BlogPost, + ollama_host: &str, + ai_did: &str, +) -> Result<()> { + // Generate translation + let translation = generate_ai_content(&post.title, "translate", ollama_host).await?; + + // Store in ai.syui.log.chat.lang collection + let record_data = serde_json::json!({ + "post_slug": extract_slug_from_url(&post.href), + "post_title": post.title, + "post_url": post.href, + "lang": "en", + "content": translation, + "generated_at": chrono::Utc::now().to_rfc3339(), + "ai_did": ai_did + }); + + store_atproto_record(client, config, &config.collections.chat_lang(), &record_data).await +} + +async fn generate_and_store_comment( + client: &reqwest::Client, + config: &AuthConfig, + post: &BlogPost, + ollama_host: &str, + ai_did: &str, +) -> Result<()> { + // Generate comment + let comment = generate_ai_content(&post.title, "comment", ollama_host).await?; + + // Store in ai.syui.log.chat.comment collection + let record_data = serde_json::json!({ + "post_slug": extract_slug_from_url(&post.href), + "post_title": post.title, + "post_url": post.href, + "content": comment, + "generated_at": chrono::Utc::now().to_rfc3339(), + "ai_did": ai_did + }); + + store_atproto_record(client, config, &config.collections.chat_comment(), &record_data).await +} + +async fn store_atproto_record( + client: &reqwest::Client, + config: &AuthConfig, + collection: &str, + record_data: &serde_json::Value, +) -> Result<()> { + let url = format!("{}/xrpc/com.atproto.repo.putRecord", config.admin.pds); + + let put_request = serde_json::json!({ + "repo": config.admin.did, + "collection": collection, + "rkey": uuid::Uuid::new_v4().to_string(), + "record": record_data + }); + + let response = client + .post(&url) + .header("Authorization", format!("Bearer {}", config.admin.access_jwt)) + .header("Content-Type", "application/json") + .json(&put_request) + .send() + .await?; + + if !response.status().is_success() { + let error_text = response.text().await?; + return Err(anyhow::anyhow!("Failed to store record: {}", error_text)); + } + Ok(()) } \ No newline at end of file diff --git a/src/generator.rs b/src/generator.rs index a3c6219..e17687d 100644 --- a/src/generator.rs +++ b/src/generator.rs @@ -71,6 +71,9 @@ impl Generator { // Generate index page self.generate_index(&posts).await?; + // Generate JSON index for API access + self.generate_json_index(&posts).await?; + // Generate post pages for post in &posts { self.generate_post_page(post).await?; @@ -446,6 +449,63 @@ impl Generator { Ok(()) } + + async fn generate_json_index(&self, posts: &[Post]) -> Result<()> { + let index_data: Vec<serde_json::Value> = posts.iter().map(|post| { + // Parse date for proper formatting + let parsed_date = chrono::NaiveDate::parse_from_str(&post.date, "%Y-%m-%d") + .unwrap_or_else(|_| chrono::Utc::now().naive_utc().date()); + + // Format to Hugo-style date format (Mon Jan 2, 2006) + let formatted_date = parsed_date.format("%a %b %-d, %Y").to_string(); + + // Create UTC datetime for utc_time field + let utc_datetime = parsed_date.and_hms_opt(0, 0, 0) + .unwrap_or_else(|| chrono::Utc::now().naive_utc()); + let utc_time = format!("{}Z", utc_datetime.format("%Y-%m-%dT%H:%M:%S")); + + // Extract plain text content from HTML + let contents = self.extract_plain_text(&post.content); + + serde_json::json!({ + "title": post.title, + "tags": post.tags, + "description": self.extract_excerpt(&post.content), + "categories": [], + "contents": contents, + "href": format!("{}{}", self.config.site.base_url.trim_end_matches('/'), post.url), + "utc_time": utc_time, + "formated_time": formatted_date + }) + }).collect(); + + // Write JSON index to public directory + let output_path = self.base_path.join("public/index.json"); + let json_content = serde_json::to_string_pretty(&index_data)?; + fs::write(output_path, json_content)?; + + println!("{} JSON index with {} posts", "Generated".cyan(), posts.len()); + + Ok(()) + } + + fn extract_plain_text(&self, html_content: &str) -> String { + // Remove HTML tags and extract plain text + let mut text = String::new(); + let mut in_tag = false; + + for ch in html_content.chars() { + match ch { + '<' => in_tag = true, + '>' => in_tag = false, + _ if !in_tag => text.push(ch), + _ => {} + } + } + + // Clean up whitespace + text.split_whitespace().collect::<Vec<_>>().join(" ") + } } #[derive(Debug, Clone, serde::Serialize)] @@ -479,4 +539,19 @@ pub struct Translation { pub title: String, pub content: String, pub url: String, -} \ No newline at end of file +} + +#[derive(Debug, Clone, serde::Serialize)] +#[allow(dead_code)] +struct BlogPost { + title: String, + url: String, + date: String, +} + +#[derive(Debug, Clone, serde::Serialize)] +#[allow(dead_code)] +struct BlogIndex { + posts: Vec<BlogPost>, +} + diff --git a/src/main.rs b/src/main.rs index 71db211..6d28840 100644 --- a/src/main.rs +++ b/src/main.rs @@ -118,6 +118,9 @@ enum StreamCommands { /// Run as daemon #[arg(short, long)] daemon: bool, + /// Enable AI content generation + #[arg(long)] + ai_generate: bool, }, /// Stop monitoring Stop, @@ -193,8 +196,8 @@ async fn main() -> Result<()> { } Commands::Stream { command } => { match command { - StreamCommands::Start { project_dir, daemon } => { - commands::stream::start(project_dir, daemon).await?; + StreamCommands::Start { project_dir, daemon, ai_generate } => { + commands::stream::start(project_dir, daemon, ai_generate).await?; } StreamCommands::Stop => { commands::stream::stop().await?;