35 Commits

Author SHA1 Message Date
594d7e7aef v0.1.7: Enhanced UI and accessibility improvements
- Add CSS styling for chat messages with theme color border
- Fix comment form visibility (only show on Comments tab)
- Remove comment form heading for cleaner UI
- Add accessibility attributes (id/name) to all form fields
- Fix Japanese input handling in Ask AI (prevent accidental submission during IME composition)
- Unified CSS classes across all content types (comments, AI chat, translations)
- Fix rkey filtering to handle .html extensions consistently

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-15 22:36:19 +09:00
be86c11e74 fix comment-tab 2025-06-15 22:34:44 +09:00
619675b551 fix post-page rkey 2025-06-15 22:22:01 +09:00
d4d98e2e91 v0.1.6: Major improvements to OAuth display and stream configuration
- Fix AI Chat History display layout and content formatting
- Unify comment layout structure across all comment types
- Remove hardcoded values from stream.rs, add config.toml support
- Optimize AI comment generation with character limits
- Improve translation length limits (3000 characters)
- Add comprehensive AI configuration management

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-15 22:22:01 +09:00
8dac463345 test update json 2025-06-15 22:22:01 +09:00
095f6ec386 v0.1.5: Unify collection configuration under VITE_OAUTH_COLLECTION
- Remove AILOG_OAUTH_COLLECTION backward compatibility
- Update stream.rs to use simplified collection structure
- Fix collection loading from project config.toml
- Resolve compiler warnings with #[allow(dead_code)]
- All systems now use unified VITE_OAUTH_COLLECTION variable

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-15 15:25:52 +09:00
c12d42882c test update 2025-06-15 15:23:32 +09:00
67b241f1e8 rm at-uri add post-url 2025-06-15 13:02:50 +09:00
4206b2195d fix post 2025-06-15 11:30:19 +09:00
b3c1b01e9e fix mobile css 2025-06-15 09:37:49 +09:00
ffa4fa0846 add scpt 2025-06-14 21:55:28 +09:00
0e75d4c0e6 fix comment input 2025-06-14 21:09:10 +09:00
b7f62e729a fix ask-AI 2025-06-14 20:48:17 +09:00
3b2c53fc97 Add GitHub Actions workflows and optimize build performance
- Add release.yml for multi-platform binary builds (Linux, macOS, Windows)
- Add gh-pages-fast.yml for fast deployment using pre-built binaries
- Add build-binary.yml for standalone binary artifact creation
- Optimize Cargo.toml with build profiles and reduced tokio features
- Remove 26MB of unused Font Awesome assets (kept only essential files)
- Font Awesome reduced from 28MB to 1.2MB

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-14 19:52:08 +09:00
13f1785081 update 2025-06-14 15:56:25 +09:00
bb6d51a602 fix css 2025-06-14 13:17:09 +09:00
a4114c5be3 fix theme 2025-06-14 13:17:09 +09:00
5c13dc0a1c fix readme 2025-06-14 13:17:09 +09:00
cef0675a88 add system 2025-06-14 13:17:09 +09:00
fd223290df code layout 2025-06-14 13:17:09 +09:00
5f4382911b fix command build 2025-06-14 13:17:09 +09:00
95cee69482 add github 2025-06-14 13:17:08 +09:00
33c166fa0c fix color 2025-06-14 13:17:08 +09:00
36863e4d9f fix loading 2025-06-14 13:17:08 +09:00
fb0e5107cf add ask AI 2025-06-14 13:17:08 +09:00
962017f922 update readme
Some checks failed
Deploy ailog / build-and-deploy (push) Failing after 12m41s
2025-06-12 20:04:57 +09:00
5ce03098bd fix stream env
Some checks are pending
Deploy ailog / build-and-deploy (push) Waiting to run
2025-06-12 19:59:19 +09:00
acce1d5af3 fix zsh
Some checks failed
Deploy ailog / build-and-deploy (push) Failing after 14m36s
2025-06-12 19:23:01 +09:00
bf0b72a52d rm oauth-env
Some checks are pending
Deploy ailog / build-and-deploy (push) Waiting to run
2025-06-12 19:12:55 +09:00
6e6c6e2f53 fix oauth
Some checks failed
Deploy ailog / build-and-deploy (push) Has been cancelled
2025-06-12 19:12:33 +09:00
eb5aa0a2be fix cargo
Some checks failed
Deploy ailog / build-and-deploy (push) Failing after 14m42s
2025-06-11 18:27:58 +09:00
ad45b151b1 fix env
Some checks failed
Deploy ailog / build-and-deploy (push) Failing after 11m20s
2025-06-11 17:31:21 +09:00
4775fa7034 fix ui
Some checks failed
Deploy ailog / build-and-deploy (push) Failing after 10m58s
2025-06-11 17:01:41 +09:00
d396dbd052 fix oauth
Some checks failed
Deploy ailog / build-and-deploy (push) Failing after 12m51s
2025-06-11 16:24:48 +09:00
ec3e3d1f89 fix run 2025-06-11 15:58:41 +09:00
138 changed files with 8671 additions and 1983 deletions

View File

@@ -33,7 +33,22 @@
"Bash(./scripts/test-oauth.sh:*)",
"Bash(./run.zsh:*)",
"Bash(npm run dev:*)",
"Bash(./target/release/ailog:*)"
"Bash(./target/release/ailog:*)",
"Bash(rg:*)",
"Bash(../target/release/ailog build)",
"Bash(zsh run.zsh:*)",
"Bash(hugo:*)",
"WebFetch(domain:docs.bsky.app)",
"WebFetch(domain:syui.ai)",
"Bash(rustup target list:*)",
"Bash(rustup target:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git push:*)",
"Bash(git tag:*)",
"Bash(../bin/ailog:*)",
"Bash(../target/release/ailog oauth build:*)",
"Bash(ailog:*)"
],
"deny": []
}

View File

@@ -0,0 +1,53 @@
name: Deploy to Cloudflare Pages
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Build ailog
run: |
cargo build --release
- name: Build OAuth app
run: |
cd oauth
npm install
npm run build
- name: Copy OAuth assets
run: |
cp -r oauth/dist/* my-blog/static/
- name: Generate site with ailog
run: |
./target/release/ailog generate --input content --output my-blog/public
- name: Deploy to Cloudflare Pages
uses: cloudflare/pages-action@v1
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
projectName: syui-ai
directory: my-blog/public
gitHubToken: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -0,0 +1,28 @@
name: Example ailog usage
on:
workflow_dispatch: # Manual trigger for testing
jobs:
build-with-ailog-action:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build with ailog action
uses: ai/log@v1 # This will reference this repository
with:
content-dir: 'content'
output-dir: 'public'
ai-integration: true
atproto-integration: true
- 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

111
.github/workflows/cloudflare-pages.yml vendored Normal file
View File

@@ -0,0 +1,111 @@
name: Deploy to Cloudflare Pages
on:
push:
branches:
- main
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '21'
- name: Install dependencies
run: |
cd oauth
npm install
- name: Build OAuth app
run: |
cd oauth
npm run build
- name: Copy OAuth build to static
run: |
# Remove old assets (following run.zsh pattern)
rm -rf my-blog/static/assets
# Copy all dist files to static
cp -rf oauth/dist/* my-blog/static/
# Copy index.html to oauth-assets.html template
cp oauth/dist/index.html my-blog/templates/oauth-assets.html
- name: Cache ailog binary
uses: actions/cache@v4
with:
path: ./bin
key: ailog-bin-${{ runner.os }}
restore-keys: |
ailog-bin-${{ runner.os }}
- name: Setup ailog binary
run: |
# Get expected version from Cargo.toml
EXPECTED_VERSION=$(grep '^version' Cargo.toml | cut -d'"' -f2)
echo "Expected version from Cargo.toml: $EXPECTED_VERSION"
# Check current binary version if exists
if [ -f "./bin/ailog" ]; then
CURRENT_VERSION=$(./bin/ailog --version 2>/dev/null || echo "unknown")
echo "Current binary version: $CURRENT_VERSION"
else
CURRENT_VERSION="none"
echo "No binary found"
fi
# Check OS
OS="${{ runner.os }}"
echo "Runner OS: $OS"
# Use pre-packaged binary if version matches or extract from tar.gz
if [ "$CURRENT_VERSION" = "$EXPECTED_VERSION" ]; then
echo "Binary is up to date"
chmod +x ./bin/ailog
elif [ "$OS" = "Linux" ] && [ -f "./bin/ailog-linux-x86_64.tar.gz" ]; then
echo "Extracting ailog from pre-packaged tar.gz..."
cd bin
tar -xzf ailog-linux-x86_64.tar.gz
chmod +x ailog
cd ..
# Verify extracted version
EXTRACTED_VERSION=$(./bin/ailog --version 2>/dev/null || echo "unknown")
echo "Extracted binary version: $EXTRACTED_VERSION"
if [ "$EXTRACTED_VERSION" != "$EXPECTED_VERSION" ]; then
echo "Warning: Binary version mismatch. Expected $EXPECTED_VERSION but got $EXTRACTED_VERSION"
fi
else
echo "Error: No suitable binary found for OS: $OS"
exit 1
fi
- name: Build site with ailog
run: |
cd my-blog
../bin/ailog build
- name: List public directory
run: |
ls -la my-blog/public/
- name: Deploy to Cloudflare Pages
uses: cloudflare/pages-action@v1
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
projectName: ${{ secrets.CLOUDFLARE_PROJECT_NAME }}
directory: my-blog/public
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
wranglerVersion: '3'

View File

@@ -1,62 +0,0 @@
name: Deploy ailog
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
profile: minimal
override: true
- name: Cache cargo registry
uses: actions/cache@v3
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
- name: Cache cargo index
uses: actions/cache@v3
with:
path: ~/.cargo/git
key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}
- name: Cache cargo build
uses: actions/cache@v3
with:
path: target
key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }}
- name: Build ailog
run: cargo build --release
- name: Generate static site
run: |
./target/release/ailog build my-blog
touch my-blog/public/.nojekyll
- name: Setup Cloudflare Pages
run: |
# Cloudflare Pages用の設定
echo '/* /index.html 200' > my-blog/public/_redirects
echo 'X-Frame-Options: DENY' > my-blog/public/_headers
echo 'X-Content-Type-Options: nosniff' >> my-blog/public/_headers
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v3
if: github.ref == 'refs/heads/main'
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./my-blog/public
publish_branch: gh-pages

View File

@@ -0,0 +1,92 @@
name: github pages (fast)
on:
push:
branches:
- main
paths-ignore:
- 'src/**'
- 'Cargo.toml'
- 'Cargo.lock'
jobs:
build-deploy:
runs-on: ubuntu-latest
permissions:
contents: write
pages: write
id-token: write
steps:
- uses: actions/checkout@v4
- name: Cache ailog binary
uses: actions/cache@v4
with:
path: ./bin
key: ailog-bin-${{ runner.os }}
restore-keys: |
ailog-bin-${{ runner.os }}
- name: Setup ailog binary
run: |
# Get expected version from Cargo.toml
EXPECTED_VERSION=$(grep '^version' Cargo.toml | cut -d'"' -f2)
echo "Expected version from Cargo.toml: $EXPECTED_VERSION"
# Check current binary version if exists
if [ -f "./bin/ailog" ]; then
CURRENT_VERSION=$(./bin/ailog --version 2>/dev/null || echo "unknown")
echo "Current binary version: $CURRENT_VERSION"
else
CURRENT_VERSION="none"
echo "No binary found"
fi
# Check OS
OS="${{ runner.os }}"
echo "Runner OS: $OS"
# Use pre-packaged binary if version matches or extract from tar.gz
if [ "$CURRENT_VERSION" = "$EXPECTED_VERSION" ]; then
echo "Binary is up to date"
chmod +x ./bin/ailog
elif [ "$OS" = "Linux" ] && [ -f "./bin/ailog-linux-x86_64.tar.gz" ]; then
echo "Extracting ailog from pre-packaged tar.gz..."
cd bin
tar -xzf ailog-linux-x86_64.tar.gz
chmod +x ailog
cd ..
# Verify extracted version
EXTRACTED_VERSION=$(./bin/ailog --version 2>/dev/null || echo "unknown")
echo "Extracted binary version: $EXTRACTED_VERSION"
if [ "$EXTRACTED_VERSION" != "$EXPECTED_VERSION" ]; then
echo "Warning: Binary version mismatch. Expected $EXPECTED_VERSION but got $EXTRACTED_VERSION"
fi
else
echo "Error: No suitable binary found for OS: $OS"
exit 1
fi
- name: Setup Hugo
uses: peaceiris/actions-hugo@v3
with:
hugo-version: "0.139.2"
extended: true
- name: Build with ailog
env:
TZ: "Asia/Tokyo"
run: |
# Use pre-built ailog binary instead of cargo build
cd my-blog
../bin/ailog build
touch ./public/.nojekyll
- name: Deploy
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./my-blog/public
publish_branch: gh-pages

169
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,169 @@
name: Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
tag:
description: 'Release tag (e.g., v1.0.0)'
required: true
default: 'v0.1.0'
permissions:
contents: write
actions: read
env:
CARGO_TERM_COLOR: always
OPENSSL_STATIC: true
OPENSSL_VENDOR: true
jobs:
build:
name: Build ${{ matrix.target }}
runs-on: ${{ matrix.os }}
timeout-minutes: 60
strategy:
matrix:
include:
- target: x86_64-unknown-linux-gnu
os: ubuntu-latest
artifact_name: ailog
asset_name: ailog-linux-x86_64
- target: aarch64-unknown-linux-gnu
os: ubuntu-latest
artifact_name: ailog
asset_name: ailog-linux-aarch64
- target: x86_64-apple-darwin
os: macos-latest
artifact_name: ailog
asset_name: ailog-macos-x86_64
- target: aarch64-apple-darwin
os: macos-latest
artifact_name: ailog
asset_name: ailog-macos-aarch64
steps:
- uses: actions/checkout@v4
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Install cross-compilation tools (Linux)
if: matrix.os == 'ubuntu-latest' && matrix.target == 'aarch64-unknown-linux-gnu'
run: |
sudo apt-get update
sudo apt-get install -y gcc-aarch64-linux-gnu binutils-aarch64-linux-gnu
- name: Configure cross-compilation (Linux ARM64)
if: matrix.target == 'aarch64-unknown-linux-gnu'
run: |
echo '[target.aarch64-unknown-linux-gnu]' >> ~/.cargo/config.toml
echo 'linker = "aarch64-linux-gnu-gcc"' >> ~/.cargo/config.toml
- name: Cache cargo registry
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Cache target directory
uses: actions/cache@v4
with:
path: target
key: ${{ runner.os }}-${{ matrix.target }}-target-${{ hashFiles('**/Cargo.lock') }}
- name: Build
run: cargo build --release --target ${{ matrix.target }}
- name: Prepare binary
shell: bash
run: |
cd target/${{ matrix.target }}/release
# Use appropriate strip command for cross-compilation
if [[ "${{ matrix.target }}" == "aarch64-unknown-linux-gnu" ]]; then
aarch64-linux-gnu-strip ${{ matrix.artifact_name }} || echo "Strip failed, continuing..."
elif [[ "${{ matrix.os }}" == "windows-latest" ]]; then
strip ${{ matrix.artifact_name }} || echo "Strip failed, continuing..."
else
strip ${{ matrix.artifact_name }} || echo "Strip failed, continuing..."
fi
# Create archive
if [[ "${{ matrix.target }}" == *"windows"* ]]; then
7z a ../../../${{ matrix.asset_name }}.zip ${{ matrix.artifact_name }}
else
tar czvf ../../../${{ matrix.asset_name }}.tar.gz ${{ matrix.artifact_name }}
fi
- name: Upload binary
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.asset_name }}
path: ${{ matrix.asset_name }}.tar.gz
release:
name: Create Release
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
actions: read
steps:
- uses: actions/checkout@v4
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Generate release notes
run: |
echo "## What's Changed" > release_notes.md
echo "" >> release_notes.md
echo "### Features" >> release_notes.md
echo "- AI-powered static blog generator" >> release_notes.md
echo "- AtProto OAuth integration" >> release_notes.md
echo "- Automatic translation support" >> release_notes.md
echo "- AI comment system" >> release_notes.md
echo "" >> release_notes.md
echo "### Platforms" >> release_notes.md
echo "- Linux (x86_64, aarch64)" >> release_notes.md
echo "- macOS (Intel, Apple Silicon)" >> release_notes.md
echo "" >> release_notes.md
echo "### Installation" >> release_notes.md
echo "\`\`\`bash" >> release_notes.md
echo "# Linux/macOS" >> release_notes.md
echo "tar -xzf ailog-linux-x86_64.tar.gz" >> release_notes.md
echo "chmod +x ailog" >> release_notes.md
echo "sudo mv ailog /usr/local/bin/" >> release_notes.md
echo "" >> release_notes.md
echo "\`\`\`" >> release_notes.md
- name: Get tag name
id: tag_name
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
echo "tag=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT
else
echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
fi
- name: Create Release
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ steps.tag_name.outputs.tag }}
name: ailog ${{ steps.tag_name.outputs.tag }}
body_path: release_notes.md
draft: false
prerelease: ${{ contains(steps.tag_name.outputs.tag, 'alpha') || contains(steps.tag_name.outputs.tag, 'beta') || contains(steps.tag_name.outputs.tag, 'rc') }}
files: artifacts/*/ailog-*.tar.gz
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

9
.gitignore vendored
View File

@@ -5,8 +5,11 @@
*.swo
*~
.DS_Store
cloudflare*
my-blog
cloudflare-config.yml
my-blog/public/
dist
package-lock.json
node_modules
package-lock.json
my-blog/static/assets/comment-atproto-*
bin/ailog
docs

View File

@@ -1,6 +1,6 @@
[package]
name = "ailog"
version = "0.1.0"
version = "0.1.7"
edition = "2021"
authors = ["syui"]
description = "A static blog generator with AI features"
@@ -15,7 +15,7 @@ clap = { version = "4.5", features = ["derive"] }
pulldown-cmark = "0.11"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1.40", features = ["full"] }
tokio = { version = "1.40", features = ["rt-multi-thread", "macros", "fs", "net", "io-util", "sync", "time", "process", "signal"] }
anyhow = "1.0"
toml = "0.8"
chrono = "0.4"
@@ -26,7 +26,7 @@ fs_extra = "1.3"
colored = "2.1"
serde_yaml = "0.9"
syntect = "5.2"
reqwest = { version = "0.12", features = ["json"] }
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
rand = "0.8"
sha2 = "0.10"
base64 = "0.22"
@@ -43,12 +43,38 @@ cookie = "0.18"
syn = { version = "2.0", features = ["full", "parsing", "visit"] }
quote = "1.0"
ignore = "0.4"
git2 = "0.18"
git2 = { version = "0.18", features = ["vendored-openssl", "vendored-libgit2", "ssh"], default-features = false }
regex = "1.0"
# ATProto and stream monitoring dependencies
tokio-tungstenite = { version = "0.21", features = ["native-tls"] }
tokio-tungstenite = { version = "0.21", features = ["rustls-tls-webpki-roots", "connect"], default-features = false }
futures-util = "0.3"
tungstenite = { version = "0.21", features = ["native-tls"] }
tungstenite = { version = "0.21", features = ["rustls-tls-webpki-roots"], default-features = false }
[dev-dependencies]
tempfile = "3.14"
tempfile = "3.14"
[profile.dev]
# Speed up development builds
opt-level = 0
debug = true
debug-assertions = true
overflow-checks = true
lto = false
panic = 'unwind'
incremental = true
codegen-units = 256
[profile.release]
# Optimize release builds for speed and size
opt-level = 3
debug = false
debug-assertions = false
overflow-checks = false
lto = true
panic = 'abort'
incremental = false
codegen-units = 1
[profile.dev.package."*"]
# Optimize dependencies in dev builds
opt-level = 3

View File

@@ -1,150 +0,0 @@
# ai.log Deployment Guide
## 🌐 Cloudflare Tunnel Setup
ATProto OAuth requires HTTPS for proper CORS handling. Use Cloudflare Tunnel for secure deployment.
### Prerequisites
1. **Install cloudflared**:
```bash
brew install cloudflared
```
2. **Login and create tunnel** (if not already done):
```bash
cloudflared tunnel login
cloudflared tunnel create ailog
```
3. **Configure DNS**:
- Add a CNAME record: `log.syui.ai` → `[tunnel-id].cfargotunnel.com`
### Configuration Files
#### `cloudflared-config.yml`
```yaml
tunnel: a6813327-f880-485d-a9d1-376e6e3df8ad
credentials-file: /Users/syui/.cloudflared/a6813327-f880-485d-a9d1-376e6e3df8ad.json
ingress:
- hostname: log.syui.ai
service: http://localhost:8080
originRequest:
noHappyEyeballs: true
- service: http_status:404
```
#### Production Client Metadata
`static/client-metadata-prod.json`:
```json
{
"client_id": "https://log.syui.ai/client-metadata.json",
"client_name": "ai.log Blog Comment System",
"client_uri": "https://log.syui.ai",
"redirect_uris": ["https://log.syui.ai/"],
"grant_types": ["authorization_code"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
"application_type": "web"
}
```
### Deployment Commands
#### Quick Start
```bash
# All-in-one deployment
./scripts/tunnel.sh
```
#### Manual Steps
```bash
# 1. Build for production
PRODUCTION=true cargo run -- build
# 2. Start local server
cargo run -- serve --port 8080 &
# 3. Start tunnel
cloudflared tunnel --config cloudflared-config.yml run
```
### Environment Detection
The system automatically detects environment:
- **Development** (`localhost:8080`): Uses local client-metadata.json
- **Production** (`log.syui.ai`): Uses HTTPS client-metadata.json
### CORS Resolution
✅ **With Cloudflare Tunnel**:
- HTTPS domain: `https://log.syui.ai`
- Valid SSL certificate
- Proper CORS headers
- ATProto OAuth works correctly
❌ **With localhost**:
- HTTP only: `http://localhost:8080`
- CORS restrictions
- ATProto OAuth may fail
### Troubleshooting
#### ATProto OAuth Errors
```javascript
// Check client metadata URL in browser console
console.log('Environment:', window.location.hostname);
console.log('Client ID:', clientId);
```
#### Tunnel Connection Issues
```bash
# Check tunnel status
cloudflared tunnel info ailog
# Test local server
curl http://localhost:8080/client-metadata.json
```
#### DNS Propagation
```bash
# Check DNS resolution
dig log.syui.ai
nslookup log.syui.ai
```
### Security Notes
- **Client metadata** is publicly accessible (required by ATProto)
- **Credentials file** contains tunnel secrets (keep secure)
- **HTTPS only** for production OAuth
- **Domain validation** by ATProto servers
### Integration with ai.ai Ecosystem
This deployment enables:
- **ai.log**: Comment system with ATProto authentication
- **ai.card**: Shared OAuth widget
- **ai.gpt**: Memory synchronization via ATProto
- **ai.verse**: Future 3D world integration
### Monitoring
```bash
# Monitor tunnel logs
cloudflared tunnel --config cloudflared-config.yml run --loglevel debug
# Monitor blog server
tail -f /path/to/blog/logs
# Check ATProto connectivity
curl -I https://log.syui.ai/client-metadata.json
```
---
**🔗 Live URL**: https://log.syui.ai
**📊 Status**: Production Ready
**🌐 ATProto**: OAuth Enabled

900
README.md
View File

@@ -4,520 +4,556 @@ AI-powered static blog generator with ATProto integration, part of the ai.ai eco
## 🚀 Quick Start
```bash
# Development
./run.zsh serve
# Production (with Cloudflare Tunnel)
./run.zsh tunnel
```
## 📋 Commands
| Command | Description |
|---------|-------------|
| `./run.zsh c` | Enable Cloudflare tunnel (log.syui.ai) for OAuth |
| `./run.zsh o` | Start OAuth web server (port:4173 = log.syui.ai) |
| `./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**: 右サイドバーに目次を自動生成
- **レスポンシブ**: モバイル・デスクトップ対応
### 🤖 AI統合機能
- **Ask AI**: ローカルLLM(Ollama)による質問応答
- **自動翻訳**: 日本語↔英語の自動生成
- **AI記事強化**: コンテンツの自動改善
- **AIコメント**: 記事への一言コメント生成
### 🌐 分散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
### Installation & Setup
```bash
cargo install ailog
# 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
```
## Usage
### Standalone Mode
### Install via Cargo
```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
cargo install --path .
# Now you can use `ailog` command globally
```
### AI Ecosystem Integration
## 📖 Core Commands
When integrated with ai.gpt, use natural language:
- "ブログ記事を書いて" → Triggers `log_ai_content`
- "記事一覧を見せて" → Triggers `log_list_posts`
- "ブログをビルドして" → Triggers `log_build_blog`
### Blog Management
```bash
# Project setup
ailog init <project-name> # Initialize new blog project
ailog new <title> # 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
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
```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
## MCP Tools
# 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
```
### 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
## 🏗️ Architecture
### 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
### Project Structure
### 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
```
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
```
### 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
### Data Flow
## Configuration
```
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.log Configuration
- Location: `~/.config/syui/ai/log/`
- Format: TOML configuration
## 🤖 AI Integration
### 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 Content Generation
## AI Integration Features
The `--ai-generate` flag enables automatic AI content generation:
### Memory-Driven Content Generation
- **Source**: ai.gpt memory system
- **Process**: Contextual memories → AI analysis → Blog content
- **Output**: Structured markdown with personal insights
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`)
### Automatic Workflows
- Daily blog posts from accumulated memories
- Content enhancement and suggestions
- Related article recommendations
- Multi-language content generation
```bash
# Start AI generation monitor
ailog stream start --ai-generate
## atproto Integration (Planned)
# 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: 静的サイトジェネレータを作った
```
### OAuth 2.0 Authentication
- Client metadata: `public/client-metadata.json`
- Comment system integration
- Data sovereignty: Users own their comments
- Collection storage in atproto
### 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
- **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
- **Real-time Comments**: ATProto-authenticated commenting
- **User Management**: Automatic user registration
- **Mobile Responsive**: Optimized for all devices
- **JSON View**: Technical record inspection
## Build & Deploy
### 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
### GitHub Actions
```yaml
# .github/workflows/gh-pages.yml
- name: Build ai.log
run: |
cd log
cargo build --release
./target/release/ailog build
```
### Setup & Configuration
### 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**
- 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
cd oauth
# Development
npm run dev
# Production build
npm run build
# Preview production
npm run preview
```
### 2. AI Ecosystem Integration
**Environment Variables:**
```bash
# Start ai.log MCP server
cargo run -- mcp --port 8002
# 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
# In another terminal, start ai.gpt
cd ../
# ai.gpt startup commands
# Simplified collection configuration (single base collection)
VITE_OAUTH_COLLECTION=ai.syui.log
# Use Claude Code with natural language blog commands
# AI Configuration
VITE_AI_ENABLED=true
VITE_AI_ASK_AI=true
VITE_AI_PROVIDER=ollama
# ... (other AI settings)
```
## Documentation Generation Features
## 🔧 Advanced Features
### 📚 Automatic README Generation
```bash
# Generate README from project analysis
ailog doc readme --source ./src --with-ai
### JSON Index Generation
# Output: Enhanced README.md with:
# - Project overview and metrics
# - Dependency analysis
# - Module structure
# - AI-generated insights
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": []
}
]
```
### 📖 API Documentation
```bash
# Generate comprehensive API docs
ailog doc api --source ./src --format markdown --output ./docs
This enables:
- **API Access**: Programmatic blog content access
- **Stream Monitoring**: AI generation triggers
- **Search Integration**: Full-text search capabilities
# Creates:
# - docs/api.md (main API overview)
# - docs/module_name.md (per-module documentation)
# - Function signatures and documentation
# - Struct/enum definitions
```
### Translation System
### 🏗️ 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:
AI-powered document translation with Ollama:
```bash
# Basic translation
ailog doc translate --input README.md --target-lang en
# Advanced translation with custom settings
# Advanced options
ailog doc translate \
--input docs/technical-guide.ja.md \
--input docs/guide.ja.md \
--target-lang en \
--source-lang ja \
--output docs/technical-guide.en.md \
--model qwen2.5:latest \
--ollama-endpoint http://localhost:11434
--output docs/guide.en.md
```
### Translation Features
**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
#### 📄 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
### MCP Server Integration
#### 🎯 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
```bash
# Start MCP server for ai.gpt integration
ailog mcp --port 8002
#### 🔧 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
# Available tools:
# - create_blog_post
# - list_blog_posts
# - build_blog
# - get_post_content
# - translate_document
# - generate_documentation
```
## 🚀 Deployment
### GitHub Actions
```yaml
name: Deploy ai.log Blog
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Rust
uses: actions-rs/toolchain@v1
with:
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: my-blog/public
```
### Production Setup
```bash
# 1. Build for production
cargo build --release
# 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. 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
```
## 🌍 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
## 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

128
action.yml Normal file
View File

@@ -0,0 +1,128 @@
name: 'ailog Static Site Generator'
description: 'AI-powered static blog generator with atproto integration'
author: 'syui'
branding:
icon: 'book-open'
color: 'orange'
inputs:
content-dir:
description: 'Content directory containing markdown files'
required: false
default: 'content'
output-dir:
description: 'Output directory for generated site'
required: false
default: 'public'
template-dir:
description: 'Template directory'
required: false
default: 'templates'
static-dir:
description: 'Static assets directory'
required: false
default: 'static'
config-file:
description: 'Configuration file path'
required: false
default: 'ailog.toml'
ai-integration:
description: 'Enable AI features'
required: false
default: 'true'
atproto-integration:
description: 'Enable atproto/OAuth features'
required: false
default: 'true'
outputs:
site-url:
description: 'Generated site URL'
value: ${{ steps.generate.outputs.site-url }}
build-time:
description: 'Build time in seconds'
value: ${{ steps.generate.outputs.build-time }}
runs:
using: 'composite'
steps:
- name: Cache ailog binary
uses: actions/cache@v4
with:
path: ./bin
key: ailog-bin-${{ runner.os }}
restore-keys: |
ailog-bin-${{ runner.os }}
- name: Setup ailog binary
shell: bash
run: |
# Check if pre-built binary exists
if [ -f "./bin/ailog-linux-x86_64" ]; then
echo "Using pre-built binary from repository"
chmod +x ./bin/ailog-linux-x86_64
CURRENT_VERSION=$(./bin/ailog-linux-x86_64 --version 2>/dev/null || echo "unknown")
echo "Binary version: $CURRENT_VERSION"
else
echo "No pre-built binary found, trying to build from source..."
if command -v cargo >/dev/null 2>&1; then
cargo build --release
mkdir -p ./bin
cp ./target/release/ailog ./bin/ailog-linux-x86_64
echo "Built from source: $(./bin/ailog-linux-x86_64 --version 2>/dev/null)"
else
echo "Error: No binary found and cargo not available"
exit 1
fi
fi
- name: Setup Node.js for OAuth app
if: ${{ inputs.atproto-integration == 'true' }}
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Build OAuth app
if: ${{ inputs.atproto-integration == 'true' }}
shell: bash
run: |
if [ -d "oauth" ]; then
cd oauth
npm install
npm run build
cp -r dist/* ../${{ inputs.static-dir }}/
fi
- name: Generate site
id: generate
shell: bash
run: |
start_time=$(date +%s)
# Change to blog directory and run build
# Note: ailog build only takes a path argument, not options
if [ -d "my-blog" ]; then
cd my-blog
../bin/ailog-linux-x86_64 build
else
# If no my-blog directory, use current directory
./bin/ailog-linux-x86_64 build .
fi
end_time=$(date +%s)
build_time=$((end_time - start_time))
echo "build-time=${build_time}" >> $GITHUB_OUTPUT
echo "site-url=file://$(pwd)/${{ inputs.output-dir }}" >> $GITHUB_OUTPUT
- name: Display build summary
shell: bash
run: |
echo "✅ ailog build completed successfully"
echo "📁 Output directory: ${{ inputs.output-dir }}"
echo "⏱️ Build time: ${{ steps.generate.outputs.build-time }}s"
if [ -d "${{ inputs.output-dir }}" ]; then
echo "📄 Generated files:"
find ${{ inputs.output-dir }} -type f | head -10
fi

1
ai_prompt.txt Normal file
View File

@@ -0,0 +1 @@
あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。

View File

@@ -1,4 +0,0 @@
# Default environment variables (fallback)
VITE_APP_HOST=https://log.syui.ai
VITE_OAUTH_CLIENT_ID=https://log.syui.ai/client-metadata.json
VITE_OAUTH_REDIRECT_URI=https://log.syui.ai/oauth/callback

View File

@@ -1,4 +0,0 @@
# Development environment variables
VITE_APP_HOST=http://localhost:4173
VITE_OAUTH_CLIENT_ID=http://localhost:4173/client-metadata.json
VITE_OAUTH_REDIRECT_URI=http://localhost:4173/oauth/callback

View File

@@ -1,4 +0,0 @@
# Production environment variables
VITE_APP_HOST=https://log.syui.ai
VITE_OAUTH_CLIENT_ID=https://log.syui.ai/client-metadata.json
VITE_OAUTH_REDIRECT_URI=https://log.syui.ai/oauth/callback

View File

@@ -1,970 +0,0 @@
import React, { useState, useEffect } from 'react';
import { OAuthCallback } from './components/OAuthCallback';
import { authService, User } from './services/auth';
import { atprotoOAuthService } from './services/atproto-oauth';
import './App.css';
function App() {
console.log('APP COMPONENT LOADED - Console working!');
console.log('Current timestamp:', new Date().toISOString());
// Immediately log URL information on every page load
console.log('IMMEDIATE URL CHECK:');
console.log('- href:', window.location.href);
console.log('- pathname:', window.location.pathname);
console.log('- search:', window.location.search);
console.log('- hash:', window.location.hash);
// Also show URL info via alert if it contains OAuth parameters
if (window.location.search.includes('code=') || window.location.search.includes('state=')) {
const urlInfo = `OAuth callback detected!\n\nURL: ${window.location.href}\nSearch: ${window.location.search}`;
alert(urlInfo);
console.log('OAuth callback URL detected!');
} else {
// Check if we have stored OAuth info from previous steps
const preOAuthUrl = sessionStorage.getItem('pre_oauth_url');
const storedState = sessionStorage.getItem('oauth_state');
const storedCodeVerifier = sessionStorage.getItem('oauth_code_verifier');
console.log('=== OAUTH SESSION STORAGE CHECK ===');
console.log('Pre-OAuth URL:', preOAuthUrl);
console.log('Stored state:', storedState);
console.log('Stored code verifier:', storedCodeVerifier ? 'Present' : 'Missing');
console.log('=== END SESSION STORAGE CHECK ===');
}
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [comments, setComments] = useState<any[]>([]);
const [commentText, setCommentText] = useState('');
const [isPosting, setIsPosting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [handleInput, setHandleInput] = useState('');
const [userListInput, setUserListInput] = useState('');
const [isPostingUserList, setIsPostingUserList] = useState(false);
const [userListRecords, setUserListRecords] = useState<any[]>([]);
const [showJsonFor, setShowJsonFor] = useState<string | null>(null);
useEffect(() => {
// Setup Jetstream WebSocket for real-time comments (optional)
const setupJetstream = () => {
try {
const ws = new WebSocket('wss://jetstream2.us-east.bsky.network/subscribe');
ws.onopen = () => {
console.log('Jetstream connected');
ws.send(JSON.stringify({
wantedCollections: ['ai.syui.log']
}));
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.collection === 'ai.syui.log' && data.commit?.operation === 'create') {
console.log('New comment detected via Jetstream:', data);
// Optionally reload comments
// loadAllComments(window.location.href);
}
} catch (err) {
console.warn('Failed to parse Jetstream message:', err);
}
};
ws.onerror = (err) => {
console.warn('Jetstream error:', err);
};
return ws;
} catch (err) {
console.warn('Failed to setup Jetstream:', err);
return null;
}
};
// Jetstream + Cache example
const jetstream = setupJetstream();
// キャッシュからコメント読み込み
const loadCachedComments = () => {
const cached = localStorage.getItem('cached_comments_' + window.location.pathname);
if (cached) {
const { comments: cachedComments, timestamp } = JSON.parse(cached);
// 5分以内のキャッシュなら使用
if (Date.now() - timestamp < 5 * 60 * 1000) {
setComments(cachedComments);
return true;
}
}
return false;
};
// キャッシュがなければ、ATProtoから取得認証状態に関係なく
if (!loadCachedComments()) {
loadAllComments(); // URLフィルタリングを無効にして全コメント表示
}
// Handle popstate events for mock OAuth flow
const handlePopState = () => {
const urlParams = new URLSearchParams(window.location.search);
const isOAuthCallback = urlParams.has('code') && urlParams.has('state');
if (isOAuthCallback) {
// Force re-render to handle OAuth callback
window.location.reload();
}
};
window.addEventListener('popstate', handlePopState);
// Check if this is an OAuth callback
const urlParams = new URLSearchParams(window.location.search);
const isOAuthCallback = urlParams.has('code') && urlParams.has('state');
if (isOAuthCallback) {
return; // Let OAuthCallback component handle this
}
// Check existing sessions
const checkAuth = async () => {
// First check OAuth session using official BrowserOAuthClient
console.log('Checking OAuth session...');
const oauthResult = await atprotoOAuthService.checkSession();
console.log('OAuth checkSession result:', oauthResult);
if (oauthResult) {
console.log('OAuth session found:', oauthResult);
// Ensure handle is not DID
const handle = oauthResult.handle !== oauthResult.did ? oauthResult.handle : oauthResult.handle;
// Get user profile including avatar
const userProfile = await getUserProfile(oauthResult.did, handle);
setUser(userProfile);
// Load all comments for display (this will be the default view)
// Temporarily disable URL filtering to see all comments
loadAllComments();
// Load user list records if admin
if (userProfile.did === 'did:plc:uqzpqmrjnptsxezjx4xuh2mn') {
loadUserListRecords();
}
setIsLoading(false);
return;
} else {
console.log('No OAuth session found');
}
// Fallback to legacy auth
const verifiedUser = await authService.verify();
if (verifiedUser) {
setUser(verifiedUser);
// Load all comments for display (this will be the default view)
// Temporarily disable URL filtering to see all comments
loadAllComments();
// Load user list records if admin
if (verifiedUser.did === 'did:plc:uqzpqmrjnptsxezjx4xuh2mn') {
loadUserListRecords();
}
}
setIsLoading(false);
// 認証状態に関係なく、コメントを読み込む
loadAllComments();
};
checkAuth();
return () => {
window.removeEventListener('popstate', handlePopState);
};
}, []);
const getUserProfile = async (did: string, handle: string): Promise<User> => {
try {
const agent = atprotoOAuthService.getAgent();
if (agent) {
const profile = await agent.getProfile({ actor: handle });
return {
did: did,
handle: handle,
avatar: profile.data.avatar,
displayName: profile.data.displayName || handle
};
}
} catch (error) {
console.error('Failed to get user profile:', error);
}
// Fallback to basic user info
return {
did: did,
handle: handle,
avatar: generatePlaceholderAvatar(handle),
displayName: handle
};
};
const generatePlaceholderAvatar = (handle: string): string => {
const initial = handle ? handle.charAt(0).toUpperCase() : 'U';
return `https://via.placeholder.com/48x48/1185fe/ffffff?text=${initial}`;
};
const loadUserComments = async (did: string) => {
try {
console.log('Loading comments for DID:', did);
const agent = atprotoOAuthService.getAgent();
if (!agent) {
console.log('No agent available');
return;
}
// Get comments from current user
const response = await agent.api.com.atproto.repo.listRecords({
repo: did,
collection: 'ai.syui.log',
limit: 100,
});
console.log('User comments loaded:', response.data);
const userComments = response.data.records || [];
// Enhance comments with profile information if missing
const enhancedComments = await Promise.all(
userComments.map(async (record) => {
if (!record.value.author?.avatar && record.value.author?.handle) {
try {
const profile = await agent.getProfile({ actor: record.value.author.handle });
return {
...record,
value: {
...record.value,
author: {
...record.value.author,
avatar: profile.data.avatar,
displayName: profile.data.displayName || record.value.author.handle,
}
}
};
} catch (err) {
console.warn('Failed to enhance comment with profile:', err);
return record;
}
}
return record;
})
);
setComments(enhancedComments);
} catch (err) {
console.error('Failed to load comments:', err);
setComments([]);
}
};
// JSONからユーザーリストを取得
const loadUsersFromRecord = async () => {
try {
// 管理者のユーザーリストを取得 (ai.syui.log.user collection)
const adminDid = 'did:plc:uqzpqmrjnptsxezjx4xuh2mn'; // syui.ai
console.log('Fetching user list from admin DID:', adminDid);
const response = await fetch(`https://bsky.social/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(adminDid)}&collection=ai.syui.log.user&limit=100`);
if (!response.ok) {
console.warn('Failed to fetch user list from admin, using default users. Status:', response.status);
return getDefaultUsers();
}
const data = await response.json();
const userRecords = data.records || [];
console.log('User records found:', userRecords.length);
if (userRecords.length === 0) {
console.log('No user records found, using default users');
return getDefaultUsers();
}
// レコードからユーザーリストを構築し、プレースホルダーDIDを実際のDIDに解決
const allUsers = [];
for (const record of userRecords) {
if (record.value.users) {
// プレースホルダーDIDを実際のDIDに解決
const resolvedUsers = await Promise.all(
record.value.users.map(async (user) => {
if (user.did && user.did.includes('-placeholder')) {
console.log(`Resolving placeholder DID for ${user.handle}`);
try {
const profileResponse = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(user.handle)}`);
if (profileResponse.ok) {
const profileData = await profileResponse.json();
if (profileData.did) {
console.log(`Resolved ${user.handle}: ${user.did} -> ${profileData.did}`);
return {
...user,
did: profileData.did
};
}
}
} catch (err) {
console.warn(`Failed to resolve DID for ${user.handle}:`, err);
}
}
return user;
})
);
allUsers.push(...resolvedUsers);
}
}
console.log('Loaded and resolved users from admin records:', allUsers);
return allUsers;
} catch (err) {
console.warn('Failed to load users from records, using defaults:', err);
return getDefaultUsers();
}
};
// ユーザーリスト一覧を読み込み
const loadUserListRecords = async () => {
try {
console.log('Loading user list records...');
const adminDid = 'did:plc:uqzpqmrjnptsxezjx4xuh2mn'; // syui.ai
const response = await fetch(`https://bsky.social/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(adminDid)}&collection=ai.syui.log.user&limit=100`);
if (!response.ok) {
console.warn('Failed to fetch user list records');
setUserListRecords([]);
return;
}
const data = await response.json();
const records = data.records || [];
// 新しい順にソート
const sortedRecords = records.sort((a, b) =>
new Date(b.value.createdAt).getTime() - new Date(a.value.createdAt).getTime()
);
console.log(`Loaded ${sortedRecords.length} user list records`);
setUserListRecords(sortedRecords);
} catch (err) {
console.error('Failed to load user list records:', err);
setUserListRecords([]);
}
};
const getDefaultUsers = () => {
const defaultUsers = [
// bsky.social - 実際のDIDを使用
{ did: 'did:plc:uqzpqmrjnptsxezjx4xuh2mn', handle: 'syui.ai', pds: 'https://bsky.social' },
];
// 現在ログインしているユーザーも追加(重複チェック)
if (user && user.did && user.handle && !defaultUsers.find(u => u.did === user.did)) {
defaultUsers.push({
did: user.did,
handle: user.handle,
pds: user.handle.endsWith('.syu.is') ? 'https://syu.is' : 'https://bsky.social'
});
}
console.log('Default users list (including current user):', defaultUsers);
return defaultUsers;
};
// 新しい関数: 全ユーザーからコメントを収集
const loadAllComments = async (pageUrl?: string) => {
try {
console.log('Loading comments from all users...');
console.log('Page URL filter:', pageUrl);
// ユーザーリストを動的に取得
const knownUsers = await loadUsersFromRecord();
console.log('Known users for comment fetching:', knownUsers);
const allComments = [];
// 各ユーザーからコメントを収集
for (const user of knownUsers) {
try {
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=ai.syui.log&limit=100`);
if (!response.ok) {
console.warn(`Failed to fetch from ${user.handle} (${response.status}): ${response.statusText}`);
continue;
}
const data = await response.json();
const userComments = data.records || [];
console.log(`Found ${userComments.length} comments from ${user.handle}`);
// ページURLでフィルタリング指定された場合
const filteredComments = pageUrl
? userComments.filter(record => record.value.url === pageUrl)
: userComments;
console.log(`After URL filtering (${pageUrl}): ${filteredComments.length} comments from ${user.handle}`);
console.log('All comments from this user:', userComments.map(r => ({ url: r.value.url, text: r.value.text })));
allComments.push(...filteredComments);
} catch (err) {
console.warn(`Failed to load comments from ${user.handle}:`, err);
}
}
// 時間順にソート(新しい順)
const sortedComments = allComments.sort((a, b) =>
new Date(b.value.createdAt).getTime() - new Date(a.value.createdAt).getTime()
);
// プロフィール情報で拡張(認証なしでも取得可能)
const enhancedComments = await Promise.all(
sortedComments.map(async (record) => {
if (!record.value.author?.avatar && record.value.author?.handle) {
try {
// Public API でプロフィール取得
const profileResponse = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(record.value.author.handle)}`);
if (profileResponse.ok) {
const profileData = await profileResponse.json();
return {
...record,
value: {
...record.value,
author: {
...record.value.author,
avatar: profileData.avatar,
displayName: profileData.displayName || record.value.author.handle,
}
}
};
}
} catch (err) {
console.warn('Failed to enhance comment with profile:', err);
}
}
return record;
})
);
console.log(`Loaded ${enhancedComments.length} comments from all users`);
// デバッグ情報を追加
console.log('Final enhanced comments:', enhancedComments);
console.log('Known users used:', knownUsers);
setComments(enhancedComments);
// キャッシュに保存5分間有効
if (pageUrl) {
const cacheKey = 'cached_comments_' + new URL(pageUrl).pathname;
const cacheData = {
comments: enhancedComments,
timestamp: Date.now()
};
localStorage.setItem(cacheKey, JSON.stringify(cacheData));
}
} catch (err) {
console.error('Failed to load all comments:', err);
setComments([]);
}
};
const handlePostComment = async () => {
if (!user || !commentText.trim()) {
return;
}
setIsPosting(true);
setError(null);
try {
const agent = atprotoOAuthService.getAgent();
if (!agent) {
throw new Error('No agent available');
}
// Create comment record with ISO datetime rkey
const now = new Date();
const rkey = now.toISOString().replace(/[:.]/g, '-'); // Replace : and . with - for valid rkey
const record = {
$type: 'ai.syui.log',
text: commentText,
url: window.location.href,
createdAt: now.toISOString(),
author: {
did: user.did,
handle: user.handle,
avatar: user.avatar,
displayName: user.displayName || user.handle,
},
};
// Post to ATProto with rkey
const response = await agent.api.com.atproto.repo.putRecord({
repo: user.did,
collection: 'ai.syui.log',
rkey: rkey,
record: record,
});
console.log('Comment posted:', response);
// Clear form and reload all comments
setCommentText('');
await loadAllComments(window.location.href);
} catch (err: any) {
console.error('Failed to post comment:', err);
setError('コメントの投稿に失敗しました: ' + err.message);
} finally {
setIsPosting(false);
}
};
const handleDeleteComment = async (uri: string) => {
if (!user) {
alert('ログインが必要です');
return;
}
if (!confirm('このコメントを削除しますか?')) {
return;
}
try {
const agent = atprotoOAuthService.getAgent();
if (!agent) {
throw new Error('No agent available');
}
// Extract rkey from URI: at://did:plc:xxx/ai.syui.log/rkey
const uriParts = uri.split('/');
const rkey = uriParts[uriParts.length - 1];
console.log('Deleting comment with rkey:', rkey);
// Delete the record
await agent.api.com.atproto.repo.deleteRecord({
repo: user.did,
collection: 'ai.syui.log',
rkey: rkey,
});
console.log('Comment deleted successfully');
// Reload all comments to reflect the deletion
await loadAllComments(window.location.href);
} catch (err: any) {
console.error('Failed to delete comment:', err);
alert('コメントの削除に失敗しました: ' + err.message);
}
};
const handleLogout = async () => {
// Logout from both services
await authService.logout();
atprotoOAuthService.logout();
setUser(null);
setComments([]);
};
// 管理者チェック
const isAdmin = (user: User | null): boolean => {
return user?.did === 'did:plc:uqzpqmrjnptsxezjx4xuh2mn'; // syui.ai
};
// ユーザーリスト投稿
const handlePostUserList = async () => {
if (!user || !userListInput.trim()) {
return;
}
if (!isAdmin(user)) {
alert('管理者のみがユーザーリストを更新できます');
return;
}
setIsPostingUserList(true);
setError(null);
try {
const agent = atprotoOAuthService.getAgent();
if (!agent) {
throw new Error('No agent available');
}
// ユーザーリストをパース
const userHandles = userListInput
.split(',')
.map(handle => handle.trim())
.filter(handle => handle.length > 0);
// ユーザーリストを各PDS用に分類し、実際のDIDを解決
const users = await Promise.all(userHandles.map(async (handle) => {
const pds = handle.endsWith('.syu.is') ? 'https://syu.is' : 'https://bsky.social';
// 実際のDIDを解決
let resolvedDid = `did:plc:${handle.replace(/\./g, '-')}-placeholder`; // フォールバック
try {
// Public APIでプロフィールを取得してDIDを解決
const profileResponse = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`);
if (profileResponse.ok) {
const profileData = await profileResponse.json();
if (profileData.did) {
resolvedDid = profileData.did;
console.log(`Resolved ${handle} -> ${resolvedDid}`);
}
}
} catch (err) {
console.warn(`Failed to resolve DID for ${handle}:`, err);
}
return {
handle: handle,
pds: pds,
did: resolvedDid
};
}));
// Create user list record with ISO datetime rkey
const now = new Date();
const rkey = now.toISOString().replace(/[:.]/g, '-');
const record = {
$type: 'ai.syui.log.user',
users: users,
createdAt: now.toISOString(),
updatedBy: {
did: user.did,
handle: user.handle,
},
};
// Post to ATProto with rkey
const response = await agent.api.com.atproto.repo.putRecord({
repo: user.did,
collection: 'ai.syui.log.user',
rkey: rkey,
record: record,
});
console.log('User list posted:', response);
// Clear form and reload user list records
setUserListInput('');
loadUserListRecords();
alert('ユーザーリストが更新されました');
} catch (err: any) {
console.error('Failed to post user list:', err);
setError('ユーザーリストの投稿に失敗しました: ' + err.message);
} finally {
setIsPostingUserList(false);
}
};
// ユーザーリスト削除
const handleDeleteUserList = async (uri: string) => {
if (!user || !isAdmin(user)) {
alert('管理者のみがユーザーリストを削除できます');
return;
}
if (!confirm('このユーザーリストを削除しますか?')) {
return;
}
try {
const agent = atprotoOAuthService.getAgent();
if (!agent) {
throw new Error('No agent available');
}
// Extract rkey from URI
const uriParts = uri.split('/');
const rkey = uriParts[uriParts.length - 1];
console.log('Deleting user list with rkey:', rkey);
// Delete the record
await agent.api.com.atproto.repo.deleteRecord({
repo: user.did,
collection: 'ai.syui.log.user',
rkey: rkey,
});
console.log('User list deleted successfully');
loadUserListRecords();
alert('ユーザーリストが削除されました');
} catch (err: any) {
console.error('Failed to delete user list:', err);
alert('ユーザーリストの削除に失敗しました: ' + err.message);
}
};
// JSON表示のトグル
const toggleJsonDisplay = (uri: string) => {
if (showJsonFor === uri) {
setShowJsonFor(null);
} else {
setShowJsonFor(uri);
}
};
// OAuth callback is now handled by React Router in main.tsx
console.log('=== APP.TSX URL CHECK ===');
console.log('Full URL:', window.location.href);
console.log('Pathname:', window.location.pathname);
console.log('Search params:', window.location.search);
console.log('=== END URL CHECK ===');
return (
<div className="app">
<main className="app-main">
<section className="comment-section">
{/* Authentication Section */}
{!user ? (
<div className="auth-section">
<button
onClick={async () => {
if (!handleInput.trim()) {
alert('Please enter your Bluesky handle first');
return;
}
try {
await atprotoOAuthService.initiateOAuthFlow(handleInput);
} catch (err) {
console.error('OAuth failed:', err);
alert('認証の開始に失敗しました。再度お試しください。');
}
}}
className="atproto-button"
>
atproto
</button>
<div className="username-input-section">
<input
type="text"
placeholder="user.bsky.social"
className="handle-input"
value={handleInput}
onChange={(e) => setHandleInput(e.target.value)}
/>
</div>
</div>
) : (
<div className="user-section">
<div className="user-info">
<div className="user-profile">
<img
src={user.avatar || generatePlaceholderAvatar(user.handle)}
alt="User Avatar"
className="user-avatar"
/>
<div className="user-details">
<h3>{user.displayName || user.handle}</h3>
<p className="user-handle">@{user.handle}</p>
<p className="user-did">DID: {user.did}</p>
</div>
</div>
<button onClick={handleLogout} className="logout-button">
Logout
</button>
</div>
{/* Admin Section - User Management */}
{isAdmin(user) && (
<div className="admin-section">
<h3> - </h3>
{/* User List Form */}
<div className="user-list-form">
<textarea
value={userListInput}
onChange={(e) => setUserListInput(e.target.value)}
placeholder="ユーザーハンドルをカンマ区切りで入力&#10;例: syui.ai, yui.syui.ai, user.bsky.social"
rows={3}
disabled={isPostingUserList}
/>
<div className="form-actions">
<span className="admin-hint"></span>
<button
onClick={handlePostUserList}
disabled={isPostingUserList || !userListInput.trim()}
className="post-button"
>
{isPostingUserList ? 'Posting...' : 'Post User List'}
</button>
</div>
</div>
{/* User List Records */}
<div className="user-list-records">
<h4> ({userListRecords.length})</h4>
{userListRecords.length === 0 ? (
<p className="no-user-lists"></p>
) : (
userListRecords.map((record, index) => (
<div key={index} className="user-list-item">
<div className="user-list-header">
<span className="user-list-date">
{new Date(record.value.createdAt).toLocaleString()}
</span>
<div className="user-list-actions">
<button
onClick={() => toggleJsonDisplay(record.uri)}
className="json-button"
title="Show/Hide JSON"
>
{showJsonFor === record.uri ? '📄 Hide JSON' : '📄 Show JSON'}
</button>
<button
onClick={() => handleDeleteUserList(record.uri)}
className="delete-button"
title="Delete user list"
>
🗑
</button>
</div>
</div>
<div className="user-list-content">
<div className="user-handles">
{record.value.users && record.value.users.map((user, userIndex) => (
<span key={userIndex} className="user-handle-tag">
{user.handle}
<small className="pds-info">({new URL(user.pds).hostname})</small>
</span>
))}
</div>
<div className="user-list-meta">
<small>URI: {record.uri}</small>
<br />
<small>Updated by: {record.value.updatedBy?.handle || 'unknown'}</small>
</div>
{/* JSON Display */}
{showJsonFor === record.uri && (
<div className="json-display">
<h5>JSON Record:</h5>
<pre className="json-content">
{JSON.stringify(record, null, 2)}
</pre>
</div>
)}
</div>
</div>
))
)}
</div>
</div>
)}
</div>
)}
{/* Comments List */}
<div className="comments-list">
<div className="comments-header">
<h3>Comments</h3>
<div className="comments-controls">
<button
onClick={() => user && loadUserComments(user.did)}
className="comments-toggle-button"
disabled={!user}
title={!user ? "Login required to view your comments" : ""}
>
My Comments {!user && "(Login Required)"}
</button>
<button
onClick={() => loadAllComments()}
className="comments-toggle-button"
>
All Comments (No Filter)
</button>
</div>
</div>
{comments.length === 0 ? (
<p className="no-comments">No comments yet</p>
) : (
comments.map((record, index) => (
<div key={index} className="comment-item">
<div className="comment-header">
<img
src={record.value.author?.avatar || generatePlaceholderAvatar(record.value.author?.handle || 'unknown')}
alt="User Avatar"
className="comment-avatar"
/>
<div className="comment-author-info">
<span className="comment-author">
{record.value.author?.displayName || record.value.author?.handle || 'unknown'}
</span>
<span className="comment-handle">@{record.value.author?.handle || 'unknown'}</span>
</div>
<span className="comment-date">
{new Date(record.value.createdAt).toLocaleString()}
</span>
{/* Show delete button only for current user's comments */}
{user && record.value.author?.did === user.did && (
<button
onClick={() => handleDeleteComment(record.uri)}
className="delete-button"
title="Delete comment"
>
🗑
</button>
)}
</div>
<div className="comment-content">
{record.value.text}
</div>
<div className="comment-meta">
<small>URI: {record.uri}</small>
</div>
</div>
))
)}
</div>
{/* Comment Form - Outside user section, after comments list */}
{user && (
<div className="comment-form">
<h3>Post a Comment</h3>
<textarea
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
placeholder="Write your comment..."
rows={4}
disabled={isPosting}
/>
<div className="form-actions">
<span className="char-count">{commentText.length} / 1000</span>
<button
onClick={handlePostComment}
disabled={isPosting || !commentText.trim() || commentText.length > 1000}
className="post-button"
>
{isPosting ? 'Posting...' : 'Post Comment'}
</button>
</div>
{error && <p className="error">{error}</p>}
</div>
)}
</section>
</main>
</div>
);
}
export default App;

View File

@@ -1,23 +0,0 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import App from './App'
import { OAuthCallbackPage } from './components/OAuthCallbackPage'
import { CardList } from './components/CardList'
import { OAuthEndpointHandler } from './utils/oauth-endpoints'
// Initialize OAuth endpoint handlers for dynamic client metadata and JWKS
// DISABLED: This may interfere with BrowserOAuthClient
// OAuthEndpointHandler.init()
ReactDOM.createRoot(document.getElementById('comment-atproto')!).render(
<React.StrictMode>
<BrowserRouter>
<Routes>
<Route path="/oauth/callback" element={<OAuthCallbackPage />} />
<Route path="/list" element={<CardList />} />
<Route path="*" element={<App />} />
</Routes>
</BrowserRouter>
</React.StrictMode>,
)

173
bin/ailog-generate.zsh Executable file
View File

@@ -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!"

Binary file not shown.

30
bin/delete-chat-records.zsh Executable file
View File

@@ -0,0 +1,30 @@
#!/bin/zsh
set -e
cb=ai.syui.log
cl=( $cb.chat $cb.chat.comment $cb.chat.lang )
f=~/.config/syui/ai/bot/token.json
default_collection="ai.syui.log.chat.comment"
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`
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`
collection=${1:-$default_collection}
pds=${2:-$default_pds}
did=${3:-$default_did}
token=${4:-$default_token}
req=com.atproto.repo.deleteRecord
url=https://$pds/xrpc/$req
for i in $cl; do
echo $i
rkeys=($(curl -sL "https://$default_pds/xrpc/com.atproto.repo.listRecords?repo=$did&collection=$i&limit=100"|jq -r ".records[]?.uri"|cut -d '/' -f 5))
for rkey in "${rkeys[@]}"; do
echo $rkey
json="{\"collection\":\"$i\", \"rkey\":\"$rkey\", \"repo\":\"$did\"}"
curl -sL -X POST -H "Content-Type: application/json" -H "Authorization: Bearer $token" -d "$json" $url
done
done

View File

@@ -1,5 +1,19 @@
# エコシステム統合設計書
## 注意事項
`console.log`は絶対に書かないようにしてください。
ハードコードしないようにしてください。必ず、`./my-blog/config.toml``./oauth/.env.production`を使用するように。または`~/.config/syui/ai/log/config.json`を使用するように。
重複する名前のenvを作らないようにしてください。新しい環境変数を作る際は必ず検討してください。
```sh
# ダメな例
VITE_OAUTH_COLLECTION_USER=ai.syui.log.user
VITE_OAUTH_COLLECTION_CHAT=ai.syui.log.chat
```
## 中核思想
- **存在子理論**: この世界で最も小さいもの(存在子/aiの探求
- **唯一性原則**: 現実の個人の唯一性をすべてのシステムで担保

18
cloudflared-config.yml Normal file
View File

@@ -0,0 +1,18 @@
tunnel: ec5a422d-7678-4e73-bf38-6105ffd4766a
credentials-file: /Users/syui/.cloudflared/ec5a422d-7678-4e73-bf38-6105ffd4766a.json
ingress:
- hostname: log.syui.ai
service: http://localhost:4173
originRequest:
noHappyEyeballs: true
- hostname: ollama.syui.ai
service: http://localhost:11434
originRequest:
noHappyEyeballs: true
httpHostHeader: "localhost:11434"
# Cloudflare Accessを無効化する場合は以下をコメントアウト
# accessPolicy: bypass
- service: http_status:404

29
my-blog/config.toml Normal file
View File

@@ -0,0 +1,29 @@
[site]
title = "syui.ai"
description = "a blog powered by ailog"
base_url = "https://syui.ai"
language = "ja"
author = "syui"
[build]
highlight_code = true
highlight_theme = "Monokai"
minify = false
[ai]
enabled = true
auto_translate = false
comment_moderation = false
ask_ai = true
provider = "ollama"
model = "gemma3:4b"
host = "https://ollama.syui.ai"
system_prompt = "あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"
ai_did = "did:plc:4hqjfn7m6n5hno3doamuhgef"
[oauth]
json = "client-metadata.json"
redirect = "oauth/callback"
admin = "did:plc:uqzpqmrjnptsxezjx4xuh2mn"
collection = "ai.syui.log"
bsky_api = "https://public.api.bsky.app"

View File

@@ -0,0 +1,159 @@
---
title: "静的サイトジェネレータを作った"
slug: "ailog"
date: "2025-06-12"
tags: ["blog", "rust", "mcp", "atp"]
language: ["ja", "en"]
---
rustで静的サイトジェネレータを作りました。[ailog](https://git.syui.ai/ai/log)といいます。`hugo`からの移行になります。
`ailog`は、最初にatproto-comment-system(oauth)とask-AIという機能をつけました。
## quick start
```sh
$ git clone https://git.syui.ai/ai/log
$ cd log
$ cargo build
$ ./target/debug/ailog init my-blog
$ ./target/debug/ailog serve my-blog
```
## install
```sh
$ cargo install --path .
---
$ export CARGO_HOME="$HOME/.cargo"
$ export RUSTUP_HOME="$HOME/.rustup"
$ export PATH="$HOME/.cargo/bin:$PATH"
---
$ which ailog
$ ailog -h
```
## build deploy
```sh
$ cd my-blog
$ vim config.toml
$ ailog new test
$ vim content/posts/`date +"%Y-%m-%d"`.md
$ ailog build
# publicの中身をweb-serverにdeploy
$ cp -rf ./public/* ./web-server/root/
```
## atproto-comment-system
### example
```sh
$ cd ./oauth
$ npm i
$ npm run build
$ npm run preview
```
```sh
# Production environment variables
VITE_APP_HOST=https://example.com
VITE_OAUTH_CLIENT_ID=https://example.com/client-metadata.json
VITE_OAUTH_REDIRECT_URI=https://example.com/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
# API Configuration
VITE_BSKY_PUBLIC_API=https://public.api.bsky.app
```
これは`ailog oauth build my-blog``./my-blog/config.toml`から`./oauth/.env.production`が生成されます。
```sh
$ ailog oauth build my-blog
```
### use
簡単に説明すると、`./oauth`で生成するのが`atproto-comment-system`です。
```html
<script type="module" crossorigin src="/assets/comment-atproto-${hash}}.js"></script>
<link rel="stylesheet" crossorigin href="/assets/comment-atproto-${hash}.css">
<section class="comment-section"> <div id="comment-atproto"></div> </section>
```
ただし、oauthであるため、色々と大変です。本番環境(もしくは近い形)でテストを行いましょう。cf, tailscale, ngrokなど。
```yml:cloudflared-config.yml
tunnel: ${hash}
credentials-file: ${path}.json
ingress:
- hostname: example.com
service: http://localhost:4173
originRequest:
noHappyEyeballs: true
- service: http_status:404
```
```sh
# tunnel list, dnsに登録が必要です
$ cloudflared tunnel list
$ cloudflared tunnel --config cloudflared-config.yml run
$ cloudflared tunnel route dns ${uuid} example.com
```
以下の2つのcollection recordを生成します。ユーザーには`ai.syui.log`が生成され、ここにコメントが記録されます。それを取得して表示しています。`ai.syui.log.user`は管理者である`VITE_ADMIN_DID`用です。
```sh
VITE_COLLECTION_COMMENT=ai.syui.log
VITE_COLLECTION_USER=ai.syui.log.user
```
```sh
$ ailog auth login
$ ailog stream server
```
このコマンドで`ai.syui.log`を`jetstream`から監視して、書き込みがあれば、管理者の`ai.syui.log.user`に記録され、そのuser-listに基づいて、コメント一覧を取得します。
つまり、コメント表示のアカウントを手動で設定するか、自動化するか。自動化するならserverで`ailog stream server`を動かさなければいけません。
## ask-AI
`ask-AI`の仕組みは割愛します。後に変更される可能性が高いと思います。
local llm, mcp, atprotoと組み合わせです。
## code syntax
```zsh:/path/to/test.zsh
# comment
d=${0:a:h}
```
```rust:/path/to/test.rs
// This is a comment
fn main() {
println!("Hello, world!");
}
```
```js:/path/to/test.js
// This is a comment
console.log("Hello, world!");
```

View File

@@ -0,0 +1,66 @@
---
title: "ブログを移行した"
slug: "blog"
date: 2025-06-14
tags: ["blog", "cloudflare", "github"]
draft: false
---
ブログを移行しました。過去のブログは[syui.github.io](https://syui.github.io)にありあます。
1. `gh-pages`から`cf-pages`への移行になります。
2. 自作の`ailog`でbuildしています。
3. 特徴としては、`atproto`, `AI`との連携です。
```yml:.github/workflows/cloudflare-pages.yml
name: Deploy to Cloudflare Pages
on:
push:
branches:
- main
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
- name: Build ailog
run: cargo build --release
- name: Build site with ailog
run: |
cd my-blog
../target/release/ailog build
- name: List public directory
run: |
ls -la my-blog/public/
- name: Deploy to Cloudflare Pages
uses: cloudflare/pages-action@v1
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
projectName: ${{ secrets.CLOUDFLARE_PROJECT_NAME }}
directory: my-blog/public
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
wranglerVersion: '3'
```
## url
- [https://syui.pages.dev](https://syui.pages.dev)
- [https://syui.github.io](https://syui.github.io)

View File

@@ -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 -}}

51
my-blog/static/_headers Normal file
View File

@@ -0,0 +1,51 @@
/*
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
X-XSS-Protection: 1; mode=block
Permissions-Policy: camera=(), microphone=(), geolocation=()
# OAuth specific headers
/oauth/*
Access-Control-Allow-Origin: https://bsky.social
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
# Static assets caching
/assets/*
Cache-Control: public, max-age=31536000, immutable
/css/*
Content-Type: text/css
Cache-Control: no-cache
/*.js
Content-Type: application/javascript
Cache-Control: public, max-age=31536000, immutable
/assets/*.js
Content-Type: application/javascript
Cache-Control: public, max-age=31536000, immutable
# Ensure ES6 modules are served correctly
/assets/comment-atproto-*.js
Content-Type: text/javascript; charset=utf-8
Cache-Control: public, max-age=31536000, immutable
# All JS assets
/assets/*-*.js
Content-Type: text/javascript; charset=utf-8
Cache-Control: public, max-age=31536000, immutable
# CSS assets
/assets/*.css
Content-Type: text/css
Cache-Control: public, max-age=60
/posts/*
Cache-Control: public, max-age=3600
# Client metadata for OAuth
/client-metadata.json
Content-Type: application/json
Cache-Control: public, max-age=3600

View File

@@ -0,0 +1,5 @@
# OAuth routes
/oauth/* /oauth/index.html 200
# SPA routing support
/* /index.html 200

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -0,0 +1,24 @@
{
"client_id": "https://syui.ai/client-metadata.json",
"client_name": "ai.card",
"client_uri": "https://syui.ai",
"logo_uri": "https://syui.ai/favicon.ico",
"tos_uri": "https://syui.ai/terms",
"policy_uri": "https://syui.ai/privacy",
"redirect_uris": [
"https://syui.ai/oauth/callback",
"https://syui.ai/"
],
"response_types": [
"code"
],
"grant_types": [
"authorization_code",
"refresh_token"
],
"token_endpoint_auth_method": "none",
"scope": "atproto transition:generic",
"subject_type": "public",
"application_type": "web",
"dpop_bound_access_tokens": true
}

View File

@@ -0,0 +1,956 @@
/* Theme Colors */
:root {
--theme-color: #f40;
--ai-color: #ff7;
--white: #fff;
--light-white: #f5f5f5;
--dark-white: #d1d9e0;
--light-gray: #f6f8fa;
--dark-gray: #666;
--background: #fff;
}
/* Base styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #1f2328;
background-color: #ffffff;
font-size: 16px;
}
/* Buttons */
button {
background: linear-gradient(135deg, #667eea, #764ba2);
color: #fff;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
font-size: 1rem;
}
/* Links */
a:any-link {
color: var(--theme-color);
text-decoration-line: unset;
cursor: pointer;
}
a:hover {
color: var(--theme-color);
opacity: 0.8;
}
/* Override link color for specific buttons */
a.view-markdown,
a.view-markdown:any-link {
color: #ffffff !important;
text-decoration: none !important;
}
/* Layout */
.container {
min-height: 100vh;
display: grid;
grid-template-rows: auto 0fr 1fr auto;
grid-template-areas:
"header"
"ask-ai"
"main"
"footer";
}
/* Header */
.main-header {
grid-area: header;
background: #ffffff;
border-bottom: 1px solid #d1d9e0;
padding: 16px 24px;
position: sticky;
top: 0;
z-index: 100;
}
.header-content {
max-width: 1000px;
margin: 0 auto;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
position: relative;
}
.site-title {
color: var(--theme-color);
text-decoration: none;
font-size: 20px;
font-weight: 600;
}
.logo {
grid-column: 2;
padding: 20px;
}
.logo .likeButton {
height: 60px;
width: auto;
cursor: pointer;
background: transparent;
border: none;
}
.header-actions {
grid-column: 3;
justify-self: end;
display: flex;
align-items: center;
}
/* Ask AI Button */
.ask-ai-btn {
background: var(--theme-color);
color: var(--white);
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
display: inline-flex;
align-items: center;
gap: 8px;
transition: background-color 0.2s;
margin: 0;
}
.ask-ai-btn:hover {
filter: brightness(1.1);
}
.ai-icon {
font-size: 16px;
width: 20px;
height: 20px;
color: var(--ai-color);
display: inline-block;
font-family: 'icomoon' !important;
speak: none;
font-style: normal;
font-weight: normal;
font-variant: normal;
text-transform: none;
line-height: 1;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Ask AI Panel */
.ask-ai-panel {
grid-area: ask-ai;
background: #f6f8fa;
border-bottom: 1px solid #d1d9e0;
padding: 24px;
overflow: hidden;
}
.ask-ai-panel[style*="block"] {
display: block !important;
}
.container:has(.ask-ai-panel[style*="block"]) {
grid-template-rows: auto auto 1fr auto;
}
.ask-ai-content {
max-width: 1000px;
margin: 0 auto;
}
.ask-ai-form {
display: flex;
gap: 12px;
margin-bottom: 16px;
}
.ask-ai-form input {
flex: 1;
padding: 8px 12px;
border: 1px solid #d1d9e0;
border-radius: 6px;
font-size: 14px;
}
.auth-check {
background: #f6f8fa;
border: 1px solid #d1d9e0;
border-radius: 6px;
padding: 16px;
text-align: center;
margin-bottom: 16px;
}
/* Main Content */
.main-content {
grid-area: main;
max-width: 1000px;
margin: 0 auto;
/* padding: 24px; */
padding-top: 80px;
width: 100%;
}
@media (max-width: 1000px) {
.main-content {
/* padding: 20px; */
padding: 0px;
max-width: 100%;
}
}
/* Timeline */
.timeline-container {
max-width: 600px;
margin: 0 auto;
}
.timeline-header h2 {
color: #1f2328;
font-size: 24px;
font-weight: 600;
text-align: center;
margin-bottom: 24px;
}
.timeline-feed {
display: flex;
flex-direction: column;
gap: 24px;
}
.timeline-post {
background: #ffffff;
border: 1px solid #d1d9e0;
border-radius: 8px;
padding: 20px;
transition: box-shadow 0.2s;
}
.timeline-post:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.post-title a {
color: #1f2328;
text-decoration: none;
font-size: 18px;
font-weight: 600;
}
.post-title a:hover {
color: var(--theme-color);
}
.post-date {
color: #656d76;
font-size: 14px;
}
.post-excerpt {
color: #656d76;
margin: 16px 0;
line-height: 1.5;
}
.post-actions {
display: flex;
gap: 16px;
align-items: center;
}
.read-more {
color: var(--theme-color);
text-decoration: none;
font-size: 14px;
font-weight: 500;
}
.read-more:hover {
text-decoration: underline;
}
.view-markdown, .view-translation {
color: #656d76;
text-decoration: none;
font-size: 14px;
padding: 4px 8px;
border-radius: 4px;
transition: all 0.2s;
}
.view-markdown {
background: var(--theme-color) !important;
color: #ffffff !important;
border: 1px solid var(--theme-color);
}
.view-markdown:hover {
filter: brightness(1.1);
color: #ffffff !important;
background: var(--theme-color) !important;
}
.view-translation:hover {
background: #f6f8fa;
}
.post-meta {
display: flex;
gap: 12px;
align-items: center;
}
.post-lang {
background: #f6f8fa;
color: #656d76;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
/* Article */
.article-container {
display: grid;
grid-template-columns: 1fr 240px;
gap: 40px;
max-width: 1000px;
margin: 0 auto;
}
article.article-content {
padding: 10px;
}
.article-meta {
display: flex;
gap: 16px;
align-items: center;
margin-bottom: 16px;
}
.article-date {
color: #656d76;
font-size: 14px;
}
.article-lang {
background: #f6f8fa;
color: #656d76;
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
.article-actions {
display: flex;
gap: 12px;
padding: 15px 0;
}
.action-btn {
color: var(--theme-color);
text-decoration: none;
font-size: 14px;
padding: 6px 12px;
border: 1px solid #d1d9e0;
border-radius: 6px;
transition: all 0.2s;
}
.action-btn:hover {
background: #f6f8fa;
border-color: var(--theme-color);
}
.markdown-btn {
background: var(--dark-white);
color: var(--white);
border-color: var(--white);
}
.markdown-btn:link,
.markdown-btn:visited {
color: var(--white) !important;
}
.markdown-btn:hover {
filter: brightness(0.9);
color: var(--theme-color) !important;
border-color: var(--white);
}
/* Sidebar styles */
.article-sidebar {
position: sticky;
top: 100px;
height: fit-content;
}
.toc {
background: #f6f8fa;
border: 1px solid #d1d9e0;
border-radius: 8px;
padding: 16px;
}
.toc h3 {
color: #1f2328;
font-size: 16px;
font-weight: 600;
margin-bottom: 12px;
}
.toc-list {
list-style: none;
padding: 0;
}
.toc-item {
margin-bottom: 8px;
}
/* Hierarchy indentation for TOC */
.toc-item.toc-h1 .toc-link {
padding-left: 0;
font-weight: 600;
font-size: 14px;
}
.toc-item.toc-h2 .toc-link {
padding-left: 0;
font-weight: 500;
font-size: 14px;
}
.toc-item.toc-h3 .toc-link {
padding-left: 16px;
font-weight: 400;
font-size: 13px;
}
.toc-item.toc-h4 .toc-link {
padding-left: 32px;
font-weight: 400;
font-size: 12px;
opacity: 0.9;
}
.toc-item.toc-h5 .toc-link {
padding-left: 48px;
font-weight: 400;
font-size: 12px;
opacity: 0.8;
}
.toc-item.toc-h6 .toc-link {
padding-left: 64px;
font-weight: 400;
font-size: 11px;
opacity: 0.7;
}
.toc-link {
color: #656d76;
text-decoration: none;
font-size: 14px;
line-height: 1.4;
display: block;
padding: 4px 0;
transition: color 0.2s;
}
.toc-link:hover {
color: var(--theme-color);
}
.article-title {
color: #1f2328;
font-size: 32px;
font-weight: 600;
margin-bottom: 16px;
line-height: 1.25;
}
.article-body {
color: #1f2328;
line-height: 1.6;
}
.article-body h1, .article-body h2, .article-body h3 {
color: #1f2328;
margin-top: 24px;
margin-bottom: 16px;
font-weight: 600;
}
.article-body p {
margin-bottom: 16px;
}
.article-body ol, .article-body ul {
margin: 16px 0;
padding-left: 24px;
}
.article-body li {
margin-bottom: 8px;
line-height: 1.6;
}
.article-body ol li {
list-style-type: decimal;
}
.article-body ul li {
list-style-type: disc;
}
.article-body pre {
background: #1B1D1E !important;
border: 1px solid #3E3D32;
border-radius: 8px;
padding: 0;
overflow: hidden;
margin: 16px 0;
font-size: 14px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
/* File name display for code blocks - top bar style */
.article-body pre[data-filename]::before {
content: attr(data-filename);
display: block;
background: #2D2D30;
color: #AE81FF;
padding: 8px 16px;
font-size: 12px;
font-family: 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', monospace;
border-bottom: 1px solid #3E3D32;
margin: 0;
width: 100%;
box-sizing: border-box;
}
.article-body pre code {
display: block;
background: none !important;
padding: 30px 16px;
color: #F8F8F2 !important;
font-family: 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', monospace;
overflow-x: auto;
line-height: 1.4;
}
/* Adjust padding when filename is present */
.article-body pre[data-filename] code {
padding: 16px;
}
/* Inline code (not in pre blocks) */
.article-body code {
background: var(--light-white);
color: var(--dark-gray);
padding: 2px 6px;
border-radius: 4px;
font-family: 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', monospace;
font-size: 13px;
}
/* Molokai syntax highlighting */
.article-body pre code .hljs-keyword { color: #F92672; }
.article-body pre code .hljs-string { color: #E6DB74; }
.article-body pre code .hljs-comment { color: #88846F; font-style: italic; }
.article-body pre code .hljs-number { color: #AE81FF; }
.article-body pre code .hljs-variable { color: #FD971F; }
.article-body pre code .hljs-function { color: #A6E22E; }
.article-body pre code .hljs-tag { color: #F92672; }
.article-body pre code .hljs-attr { color: #A6E22E; }
.article-body pre code .hljs-value { color: #E6DB74; }
/* Fix inline span colors in code blocks */
.article-body pre code span[style*="color:#8fa1b3"] { color: #AE81FF !important; } /* $ prompt */
.article-body pre code span[style*="color:#c0c5ce"] { color: #F8F8F2 !important; } /* commands */
.article-body pre code span[style*="color:#75715E"] { color: #88846F !important; } /* real comments */
/* Shell/Bash specific fixes */
.article-body pre code span[style*="color:#65737e"] {
color: #F8F8F2 !important; /* Default to white for variables and code */
}
/* Comments in shell scripts - lines that contain # followed by text */
.article-body pre code span[style*="color:#65737e"]:has-text("#") {
color: #88846F !important;
}
/* Alternative approach - check content */
.article-body pre code {
/* Reset all gray colored text to white by default */
}
.article-body pre code span[style*="color:#65737e"] {
/* Check if the content starts with # */
color: #F8F8F2 !important;
}
/* Override for actual comments - this is a workaround */
.article-body pre code span[style*="color:#65737e"]:first-child:before {
content: attr(data-comment);
}
/* Detect comments by position and content pattern */
.article-body pre code span[style*="color:#65737e"] {
color: #F8F8F2 !important; /* Environment variables = white */
}
/* Only style as comment if the line actually starts with # */
.article-body pre code > span:first-child[style*="color:#65737e"] {
color: #88846F !important; /* Real comments = gray */
}
/* Footer */
.main-footer {
grid-area: footer;
background: var(--light-white);
border-top: 1px solid #d1d9e0;
padding: 32px 24px;
text-align: center;
}
.footer-social {
display: flex;
justify-content: center;
gap: 24px;
margin: 40px;
}
.footer-social a {
color: var(--dark-gray) !important;
text-decoration: none !important;
font-size: 16px;
transition: all 0.2s ease;
}
.footer-social a:hover {
color: var(--theme-color) !important;
opacity: 0.8;
}
.main-footer p {
color: #656d76;
font-size: 14px;
margin: 0;
}
/* Chat Messages */
.chat-message.comment-style {
background: #ffffff;
border: 1px solid #d1d9e0;
border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
}
.chat-message.ai-message.comment-style {
border-left: 4px solid var(--ai-color);
background: #faf8ff;
}
.message-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.message-header .avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: #f6f8fa;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
border: 1px solid #d1d9e0;
}
.display-name {
font-weight: 600;
color: #1f2328;
font-size: 14px;
}
.handle {
color: #656d76;
font-size: 13px;
}
.timestamp {
color: #656d76;
font-size: 12px;
}
.message-content {
color: #1f2328;
line-height: 1.5;
white-space: pre-wrap;
}
.profile-avatar {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
/* Loading Animation */
.ai-loading-simple {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 15px;
background: linear-gradient(90deg, #f8f9fa 0%, #e9ecef 100%);
border-radius: 20px;
margin: 8px 0;
font-size: 14px;
color: #495057;
border: 1px solid #dee2e6;
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Comment System Styles */
.comment-section {
max-width: 800px;
margin: 0 auto;
margin-top: 48px;
padding-top: 32px;
}
.comment-container {
max-width: 600px;
margin: 0 auto;
text-align: center;
}
.comment-section h3 {
color: #1f2328;
font-size: 24px;
font-weight: 600;
margin-bottom: 32px;
text-align: center;
}
/* OAuth Comment System - Hide on homepage by default, show on post pages */
.timeline-container .comment-section {
display: block; /* Show on homepage */
}
.timeline-container .comment-section .comments-list > :nth-child(n+6) {
display: none; /* Hide comments after the 5th one */
}
.article-container .comment-section,
.article-content + .comment-section {
display: block; /* Show all comments on post pages */
}
/* Responsive */
@media (max-width: 1000px) {
.article-container {
grid-template-columns: 1fr;
gap: 24px;
max-width: 100%;
padding: 0;
margin: 0;
}
}
@media (max-width: 1000px) {
.main-header {
padding: 0px;
}
.header-content {
max-width: 100%;
padding: 0 20px;
grid-template-columns: auto 1fr auto;
gap: 0;
}
/* OAuth app mobile fixes */
.comment-item {
padding: 0px !important;
margin: 0px !important;
}
.auth-section {
padding: 0px !important;
}
.comments-list {
padding: 0px !important;
}
.comment-section {
padding: 0px !important;
margin: 0px !important;
}
.comment-content {
padding: 10px !important;
word-wrap: break-word !important;
overflow-wrap: break-word !important;
}
.comment-header {
padding: 10px !important;
}
/* Fix comment-meta URI overflow */
.comment-meta {
word-break: break-all !important;
overflow-wrap: break-word !important;
}
/* Hide site title text on mobile */
.site-title {
display: none;
}
/* Left align logo on mobile */
.logo {
grid-column: 1;
justify-self: left;
padding: 5px;
display: flex;
justify-content: flex-start;
align-items: center;
}
/* Reduce logo size on mobile */
.logo .likeButton {
width: 40pt;
height: 40pt;
}
/* Position AI button on the right */
.header-actions {
grid-column: 3;
justify-self: end;
}
/* Ask AI button mobile style - icon only */
.ask-ai-btn {
padding: 8px;
min-width: 40px;
justify-content: center;
gap: 0;
font-size: 0; /* Hide all text content */
}
.ask-ai-btn .ai-icon {
margin: 0;
font-size: 16px;
}
.ask-ai-panel {
padding: 16px;
}
/* Article content mobile optimization */
.article-body {
overflow-x: hidden;
}
.article-body pre {
margin: 16px 0;
border-radius: 4px;
max-width: 100%;
overflow-x: auto;
}
.article-body pre code {
padding: 20px 12px;
word-wrap: break-word;
white-space: pre-wrap;
}
/* Mobile filename display */
.article-body pre[data-filename]::before {
padding: 6px 12px;
font-size: 11px;
}
.article-body pre[data-filename] code {
padding: 12px;
}
.article-body code {
word-break: break-all;
}
.ask-ai-form {
flex-direction: column;
}
.timeline-container {
max-width: 100%;
padding: 0;
}
.timeline-post {
padding: 16px;
}
.article-title {
font-size: 24px;
padding: 30px 0px;
}
.message-header .avatar {
width: 32px;
height: 32px;
font-size: 16px;
}
/* Center content on mobile */
body {
margin: 0;
padding: 0;
}
.container {
width: 100%;
padding: 0;
}
}

View File

@@ -0,0 +1,342 @@
/* SVG Animation Package - Dependency-free standalone package
* Based on svg-animation-particle-circle.css
* Theme color integration with CSS variables
*/
/* Theme-based color variables for particles */
:root {
--particle-color-1: #f40; /* theme-color base */
--particle-color-2: #f50; /* theme-color +0.1 brightness */
--particle-color-3: #f60; /* theme-color +0.2 brightness */
--particle-color-4: #f70; /* theme-color +0.3 brightness */
--particle-color-5: #f80; /* theme-color +0.4 brightness */
--explosion-color: #f30; /* theme-color -0.1 brightness */
--syui-color: #f40; /* main theme color */
}
/* Core SVG button setup */
.likeButton {
cursor: pointer;
display: inline-block;
}
/* Remove debug animation and restore hover functionality */
.likeButton .border {
fill: white;
}
/* Explosion circle - initially hidden */
.likeButton .explosion {
transform-origin: center center;
transform: scale(1);
stroke: var(--explosion-color);
fill: none;
opacity: 0;
stroke-width: 1;
}
/* Particle layer - initially hidden */
.likeButton .particleLayer {
opacity: 0;
transform: scale(0); /* Ensure particles start hidden */
}
.likeButton .particleLayer circle {
opacity: 0;
transform-origin: center center; /* Fixed from 250px 250px */
transform: scale(0);
}
/* Syui logo - main animation target */
.likeButton .syui {
fill: var(--syui-color);
transform: scale(1);
transform-origin: center center;
}
/* Hover trigger - replaces jQuery */
.likeButton:hover .explosion {
animation: explosionAnime 800ms forwards;
}
.likeButton:hover .particleLayer {
animation: particleLayerAnime 800ms forwards;
}
.likeButton:hover .syui,
.likeButton:hover path.syui {
animation: syuiDeluxeAnime 400ms forwards;
}
/* Individual particle animations */
.likeButton:hover .particleLayer circle:nth-child(1) {
animation: particleAnimate1 800ms;
animation-fill-mode: forwards;
}
.likeButton:hover .particleLayer circle:nth-child(2) {
animation: particleAnimate2 800ms;
animation-fill-mode: forwards;
}
.likeButton:hover .particleLayer circle:nth-child(3) {
animation: particleAnimate3 800ms;
animation-fill-mode: forwards;
}
.likeButton:hover .particleLayer circle:nth-child(4) {
animation: particleAnimate4 800ms;
animation-fill-mode: forwards;
}
.likeButton:hover .particleLayer circle:nth-child(5) {
animation: particleAnimate5 800ms;
animation-fill-mode: forwards;
}
.likeButton:hover .particleLayer circle:nth-child(6) {
animation: particleAnimate6 800ms;
animation-fill-mode: forwards;
}
.likeButton:hover .particleLayer circle:nth-child(7) {
animation: particleAnimate7 800ms;
animation-fill-mode: forwards;
}
.likeButton:hover .particleLayer circle:nth-child(8) {
animation: particleAnimate8 800ms;
animation-fill-mode: forwards;
}
.likeButton:hover .particleLayer circle:nth-child(9) {
animation: particleAnimate9 800ms;
animation-fill-mode: forwards;
}
.likeButton:hover .particleLayer circle:nth-child(10) {
animation: particleAnimate10 800ms;
animation-fill-mode: forwards;
}
.likeButton:hover .particleLayer circle:nth-child(11) {
animation: particleAnimate11 800ms;
animation-fill-mode: forwards;
}
.likeButton:hover .particleLayer circle:nth-child(12) {
animation: particleAnimate12 800ms;
animation-fill-mode: forwards;
}
.likeButton:hover .particleLayer circle:nth-child(13) {
animation: particleAnimate13 800ms;
animation-fill-mode: forwards;
}
.likeButton:hover .particleLayer circle:nth-child(14) {
animation: particleAnimate14 800ms;
animation-fill-mode: forwards;
}
/* Keyframe animations */
@keyframes explosionAnime {
0% {
opacity: 0;
transform: scale(0.01);
}
1% {
opacity: 1;
transform: scale(0.01);
}
5% {
stroke-width: 200;
}
20% {
stroke-width: 300;
}
50% {
stroke: var(--particle-color-3);
transform: scale(1.1);
stroke-width: 1;
}
50.1% {
stroke-width: 0;
}
100% {
stroke: var(--particle-color-3);
transform: scale(1.1);
stroke-width: 0;
}
}
@keyframes particleLayerAnime {
0% {
transform: translate(0, 0);
opacity: 0;
}
30% {
opacity: 0;
}
31% {
opacity: 1;
}
60% {
transform: translate(0, 0);
}
70% {
opacity: 1;
}
100% {
opacity: 0;
transform: translate(0, -20px);
}
}
/* Syui Deluxe Animation - Based on 2019 blog post */
@keyframes syuiDeluxeAnime {
0% {
fill: var(--syui-color);
transform: scale(1) translate(0%, 0%);
}
40% {
fill: color-mix(in srgb, var(--syui-color) 40%, transparent);
transform: scale(1, 0.9) translate(-9%, 9%);
}
50% {
fill: color-mix(in srgb, var(--syui-color) 70%, transparent);
transform: scale(1, 0.9) translate(-7%, 7%);
}
60% {
transform: scale(1) translate(-7%, 7%);
}
70% {
transform: scale(1.04) translate(-5%, 5%);
}
80% {
fill: color-mix(in srgb, var(--syui-color) 60%, transparent);
transform: scale(1.04) translate(-5%, 5%);
}
90% {
fill: var(--particle-color-5); /* 爆発の閃光 */
transform: scale(1) translate(0%);
}
100% {
fill: var(--syui-color);
transform: scale(1, 1) translate(0%, 0%);
}
}
/* Individual particle animations */
@keyframes particleAnimate1 {
0% { transform: translate(0, 0); }
30% { opacity: 1; transform: translate(0, 0); }
80% { transform: translate(-16px, -59px); }
90% { transform: translate(-16px, -59px); }
100% { opacity: 1; transform: translate(-16px, -59px); }
}
@keyframes particleAnimate2 {
0% { transform: translate(0, 0); }
30% { opacity: 1; transform: translate(0, 0); }
80% { transform: translate(41px, 43px); }
90% { transform: translate(41px, 43px); }
100% { opacity: 1; transform: translate(41px, 43px); }
}
@keyframes particleAnimate3 {
0% { transform: translate(0, 0); }
30% { opacity: 1; transform: translate(0, 0); }
80% { transform: translate(50px, -48px); }
90% { transform: translate(50px, -48px); }
100% { opacity: 1; transform: translate(50px, -48px); }
}
@keyframes particleAnimate4 {
0% { transform: translate(0, 0); }
30% { opacity: 1; transform: translate(0, 0); }
80% { transform: translate(-39px, 36px); }
90% { transform: translate(-39px, 36px); }
100% { opacity: 1; transform: translate(-39px, 36px); }
}
@keyframes particleAnimate5 {
0% { transform: translate(0, 0); }
30% { opacity: 1; transform: translate(0, 0); }
80% { transform: translate(-39px, 32px); }
90% { transform: translate(-39px, 32px); }
100% { opacity: 1; transform: translate(-39px, 32px); }
}
@keyframes particleAnimate6 {
0% { transform: translate(0, 0); }
30% { opacity: 1; transform: translate(0, 0); }
80% { transform: translate(48px, 6px); }
90% { transform: translate(48px, 6px); }
100% { opacity: 1; transform: translate(48px, 6px); }
}
@keyframes particleAnimate7 {
0% { transform: translate(0, 0); }
30% { opacity: 1; transform: translate(0, 0); }
80% { transform: translate(-69px, -36px); }
90% { transform: translate(-69px, -36px); }
100% { opacity: 1; transform: translate(-69px, -36px); }
}
@keyframes particleAnimate8 {
0% { transform: translate(0, 0); }
30% { opacity: 1; transform: translate(0, 0); }
80% { transform: translate(-12px, -52px); }
90% { transform: translate(-12px, -52px); }
100% { opacity: 1; transform: translate(-12px, -52px); }
}
@keyframes particleAnimate9 {
0% { transform: translate(0, 0); }
30% { opacity: 1; transform: translate(0, 0); }
80% { transform: translate(-43px, -21px); }
90% { transform: translate(-43px, -21px); }
100% { opacity: 1; transform: translate(-43px, -21px); }
}
@keyframes particleAnimate10 {
0% { transform: translate(0, 0); }
30% { opacity: 1; transform: translate(0, 0); }
80% { transform: translate(-10px, 47px); }
90% { transform: translate(-10px, 47px); }
100% { opacity: 1; transform: translate(-10px, 47px); }
}
@keyframes particleAnimate11 {
0% { transform: translate(0, 0); }
30% { opacity: 1; transform: translate(0, 0); }
80% { transform: translate(66px, -9px); }
90% { transform: translate(66px, -9px); }
100% { opacity: 1; transform: translate(66px, -9px); }
}
@keyframes particleAnimate12 {
0% { transform: translate(0, 0); }
30% { opacity: 1; transform: translate(0, 0); }
80% { transform: translate(40px, -45px); }
90% { transform: translate(40px, -45px); }
100% { opacity: 1; transform: translate(40px, -45px); }
}
@keyframes particleAnimate13 {
0% { transform: translate(0, 0); }
30% { opacity: 1; transform: translate(0, 0); }
80% { transform: translate(29px, 24px); }
90% { transform: translate(29px, 24px); }
100% { opacity: 1; transform: translate(29px, 24px); }
}
@keyframes particleAnimate14 {
0% { transform: translate(0, 0); }
30% { opacity: 1; transform: translate(0, 0); }
80% { transform: translate(-10px, 50px); }
90% { transform: translate(-10px, 50px); }
100% { opacity: 1; transform: translate(-10px, 50px); }
}

BIN
my-blog/static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

BIN
my-blog/static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -0,0 +1,22 @@
<svg width="77pt" height="77pt" viewBox="0 0 512 512" class="likeButton" >
<circle class="explosion" r="150" cx="250" cy="250"></circle>
<g class="particleLayer">
<circle fill="#ef454aba" cx="130" cy="126.5" r="12.5"/>
<circle fill="#ef454acc" cx="411" cy="313.5" r="12.5"/>
<circle fill="#ef454aba" cx="279" cy="86.5" r="12.5"/>
<circle fill="#ef454aba" cx="155" cy="390.5" r="12.5"/>
<circle fill="#ef454aba" cx="89" cy="292.5" r="10.5"/>
<circle fill="#ef454aba" cx="414" cy="282.5" r="10.5"/>
<circle fill="#ef454a91" cx="115" cy="149.5" r="10.5"/>
<circle fill="#ef454aba" cx="250" cy="80.5" r="10.5"/>
<circle fill="#ef454aba" cx="78" cy="261.5" r="10.5"/>
<circle fill="#ef454a91" cx="182" cy="402.5" r="10.5"/>
<circle fill="#ef454aba" cx="401.5" cy="166" r="13"/>
<circle fill="#ef454aba" cx="379" cy="141.5" r="10.5"/>
<circle fill="#ef454a91" cx="327" cy="397.5" r="10.5"/>
<circle fill="#ef454aba" cx="296" cy="392.5" r="10.5"/>
</g>
<g transform="translate(0,512) scale(0.1,-0.1)" fill="#000000" class="icon_syui">
<path class="syui" d="M3660 4460 c-11 -11 -33 -47 -48 -80 l-29 -60 -12 38 c-27 88 -58 92 -98 11 -35 -70 -73 -159 -73 -169 0 -6 -5 -10 -10 -10 -6 0 -15 -10 -21 -22 -33 -73 -52 -92 -47 -48 2 26 -1 35 -14 38 -16 3 -168 -121 -168 -138 0 -5 -13 -16 -28 -24 -24 -13 -35 -12 -87 0 -221 55 -231 56 -480 56 -219 1 -247 -1 -320 -22 -44 -12 -96 -26 -115 -30 -57 -13 -122 -39 -200 -82 -8 -4 -31 -14 -50 -23 -41 -17 -34 -13 -146 -90 -87 -59 -292 -252 -351 -330 -63 -83 -143 -209 -143 -225 0 -10 -7 -23 -15 -30 -8 -7 -15 -17 -15 -22 0 -5 -13 -37 -28 -71 -16 -34 -36 -93 -45 -132 -9 -38 -24 -104 -34 -145 -13 -60 -17 -121 -17 -300 1 -224 1 -225 36 -365 24 -94 53 -175 87 -247 28 -58 51 -108 51 -112 0 -3 13 -24 28 -48 42 -63 46 -79 22 -85 -11 -3 -20 -9 -20 -14 0 -5 -4 -9 -10 -9 -5 0 -22 -11 -37 -25 -16 -13 -75 -59 -133 -100 -58 -42 -113 -82 -123 -90 -9 -8 -22 -15 -27 -15 -6 0 -10 -6 -10 -13 0 -8 -11 -20 -25 -27 -34 -18 -34 -54 0 -48 14 3 25 2 25 -1 0 -3 -43 -31 -95 -61 -52 -30 -95 -58 -95 -62 0 -5 -5 -8 -11 -8 -19 0 -84 -33 -92 -47 -4 -7 -15 -13 -22 -13 -14 0 -17 -4 -19 -32 -1 -8 15 -15 37 -18 l38 -5 -47 -48 c-56 -59 -54 -81 9 -75 30 3 45 0 54 -11 9 -13 16 -14 43 -4 29 11 30 10 18 -5 -7 -9 -19 -23 -25 -30 -7 -7 -13 -20 -13 -29 0 -12 8 -14 38 -9 20 4 57 8 82 9 25 2 54 8 66 15 18 10 23 8 32 -13 17 -38 86 -35 152 6 27 17 50 34 50 38 0 16 62 30 85 19 33 -15 72 -2 89 30 8 15 31 43 51 62 35 34 38 35 118 35 77 0 85 2 126 33 24 17 52 32 61 32 9 0 42 18 73 40 30 22 61 40 69 40 21 0 88 -26 100 -38 7 -7 17 -12 24 -12 7 0 35 -11 62 -25 66 -33 263 -84 387 -101 189 -25 372 -12 574 41 106 27 130 37 261 97 41 20 80 37 85 39 6 2 51 31 100 64 166 111 405 372 489 534 10 20 22 43 27 51 5 8 12 22 15 30 3 8 17 40 31 70 54 115 95 313 108 520 13 200 -43 480 -134 672 -28 58 -51 108 -51 112 0 3 -13 24 -29 48 -15 24 -34 60 -40 80 -19 57 3 142 50 193 10 11 22 49 28 85 6 36 16 67 21 68 18 6 31 53 25 83 -4 18 -17 33 -36 41 -16 7 -29 15 -29 18 1 10 38 50 47 50 5 0 20 11 33 25 18 19 22 31 17 61 -3 20 -14 45 -23 55 -16 18 -16 20 6 44 15 16 21 32 18 49 -3 15 1 34 8 43 32 43 7 73 -46 55 l-30 -11 0 85 c0 74 -2 84 -18 84 -21 0 -53 -33 -103 -104 l-34 -48 -5 74 c-7 102 -35 133 -80 88z m-870 -740 c36 -7 75 -14 88 -16 21 -4 23 -9 16 -37 -3 -18 -14 -43 -24 -57 -10 -14 -20 -35 -24 -46 -4 -12 -16 -32 -27 -45 -12 -13 -37 -49 -56 -79 -20 -30 -52 -73 -72 -96 -53 -60 -114 -133 -156 -189 -21 -27 -44 -54 -52 -58 -7 -4 -13 -14 -13 -22 0 -7 -18 -33 -40 -57 -22 -23 -40 -46 -40 -50 0 -5 -19 -21 -42 -38 -47 -35 -85 -38 -188 -15 -115 25 -173 20 -264 -23 -45 -22 -106 -46 -136 -56 -48 -15 -77 -25 -140 -50 -70 -28 -100 -77 -51 -84 14 -2 34 -10 45 -17 12 -7 53 -16 91 -20 90 -9 131 -22 178 -57 20 -16 52 -35 70 -43 18 -7 40 -22 49 -32 16 -18 15 -22 -24 -88 -23 -39 -47 -74 -53 -80 -7 -5 -23 -26 -36 -45 -26 -39 -92 -113 -207 -232 -4 -4 -37 -36 -73 -71 l-66 -64 -20 41 c-58 119 -105 240 -115 301 -40 244 -35 409 20 595 8 30 21 66 28 80 7 14 24 54 38 89 15 35 35 75 46 89 11 13 20 31 20 38 0 8 3 14 8 14 4 0 16 16 27 36 24 45 221 245 278 281 23 15 44 30 47 33 20 20 138 78 250 123 61 24 167 50 250 61 60 7 302 -1 370 -14z m837 -661 c52 -101 102 -279 106 -379 2 -42 0 -45 -28 -51 -16 -4 -101 -7 -187 -8 -166 -1 -229 10 -271 49 -19 19 -19 19 14 49 22 21 44 31 65 31 41 0 84 34 84 66 0 30 12 55 56 112 19 25 37 65 44 95 11 51 53 111 74 104 6 -2 25 -32 43 -68z m-662 -810 c17 -10 40 -24 53 -30 12 -7 22 -16 22 -20 0 -4 17 -13 38 -19 20 -7 44 -18 52 -24 8 -7 33 -21 55 -31 22 -11 42 -23 45 -26 11 -14 109 -49 164 -58 62 -11 101 -7 126 14 15 14 38 18 78 16 39 -2 26 -41 -49 -146 -78 -109 -85 -118 -186 -219 -61 -61 -239 -189 -281 -203 -17 -5 -73 -29 -104 -44 -187 -92 -605 -103 -791 -21 -42 19 -47 24 -37 41 5 11 28 32 51 48 22 15 51 38 64 51 13 12 28 22 33 22 17 0 242 233 242 250 0 6 5 10 10 10 6 0 10 6 10 14 0 25 50 55 100 62 59 8 56 6 115 83 50 66 74 117 75 162 0 14 7 40 16 57 18 38 52 41 99 11z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

@@ -0,0 +1,3 @@
<!-- OAuth Comment System - Load globally for session management -->
<script type="module" crossorigin src="/assets/comment-atproto-mfW-OeY_.js"></script>
<link rel="stylesheet" crossorigin href="/assets/comment-atproto-Cm5qR-aM.css">

31
my-blog/static/index.json Normal file
View File

@@ -0,0 +1,31 @@
[
{
"categories": [],
"contents": "ブログを移行しました。過去のブログはsyui.github.ioにありあます。 gh-pagesからcf-pagesへの移行になります。 自作のailogでbuildしています。 特徴としては、atproto, AIとの連携です。 name: Deploy to Cloudflare Pages on: push: branches: - main workflow_dispatch: jobs: deploy: runs-on: ubuntu-latest permissions: contents: read deployments: write steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Rust uses: actions-rs/toolchain@v1 with: toolchain: stable - name: Build ailog run: cargo build --release - name: Build site with ailog run: | cd my-blog ../target/release/ailog build - name: List public directory run: | ls -la my-blog/public/ - name: Deploy to Cloudflare Pages uses: cloudflare/pages-action@v1 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} projectName: ${{ secrets.CLOUDFLARE_PROJECT_NAME }} directory: my-blog/public gitHubToken: ${{ secrets.GITHUB_TOKEN }} wranglerVersion: &#39;3&#39; url https://syui.pages.dev https://syui.github.io",
"description": "ブログを移行しました。過去のブログはsyui.github.ioにありあます。 \n\ngh-pagesからcf-pagesへの移行になります。\n自作のailogでbuildしています。\n特徴としては、atproto, AIとの連携です。\n\nname: Deploy to Cloudflare Pages\n\non:\n push:\n branches:\n - main\n workfl...",
"formated_time": "Sat Jun 14, 2025",
"href": "https://syui.ai/posts/2025-06-14-blog.html",
"tags": [
"blog",
"cloudflare",
"github"
],
"title": "ブログを移行した",
"utc_time": "2025-06-14T00:00:00Z"
},
{
"categories": [],
"contents": "rustで静的サイトジェネレータを作りました。ailogといいます。hugoからの移行になります。 ailogは、最初にatproto-comment-system(oauth)とask-AIという機能をつけました。 quick start $ git clone https://git.syui.ai/ai/log $ cd log $ cargo build $ ./target/debug/ailog init my-blog $ ./target/debug/ailog serve my-blog install $ cargo install --path . --- $ export CARGO_HOME=&quot;$HOME/.cargo&quot; $ export RUSTUP_HOME=&quot;$HOME/.rustup&quot; $ export PATH=&quot;$HOME/.cargo/bin:$PATH&quot; --- $ which ailog $ ailog -h build deploy $ cd my-blog $ vim config.toml $ ailog new test $ vim content/posts/`date +&quot;%Y-%m-%d&quot;`.md $ ailog build # publicの中身をweb-serverにdeploy $ cp -rf ./public/* ./web-server/root/ atproto-comment-system example $ cd ./oauth $ npm i $ npm run build $ npm run preview # Production environment variables VITE_APP_HOST=https://example.com VITE_OAUTH_CLIENT_ID=https://example.com/client-metadata.json VITE_OAUTH_REDIRECT_URI=https://example.com/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 # API Configuration VITE_BSKY_PUBLIC_API=https://public.api.bsky.app これはailog oauth build my-blogで./my-blog/config.tomlから./oauth/.env.productionが生成されます。 $ ailog oauth build my-blog use 簡単に説明すると、./oauthで生成するのがatproto-comment-systemです。 &lt;script type=&quot;module&quot; crossorigin src=&quot;/assets/comment-atproto-${hash}}.js&quot;&gt;&lt;/script&gt; &lt;link rel=&quot;stylesheet&quot; crossorigin href=&quot;/assets/comment-atproto-${hash}.css&quot;&gt; &lt;section class=&quot;comment-section&quot;&gt; &lt;div id=&quot;comment-atproto&quot;&gt;&lt;/div&gt; &lt;/section&gt; ただし、oauthであるため、色々と大変です。本番環境(もしくは近い形)でテストを行いましょう。cf, tailscale, ngrokなど。 tunnel: ${hash} credentials-file: ${path}.json ingress: - hostname: example.com service: http://localhost:4173 originRequest: noHappyEyeballs: true - service: http_status:404 # tunnel list, dnsに登録が必要です $ cloudflared tunnel list $ cloudflared tunnel --config cloudflared-config.yml run $ cloudflared tunnel route dns ${uuid} example.com 以下の2つのcollection recordを生成します。ユーザーにはai.syui.logが生成され、ここにコメントが記録されます。それを取得して表示しています。ai.syui.log.userは管理者であるVITE_ADMIN_DID用です。 VITE_COLLECTION_COMMENT=ai.syui.log VITE_COLLECTION_USER=ai.syui.log.user $ ailog auth login $ ailog stream server このコマンドでai.syui.logをjetstreamから監視して、書き込みがあれば、管理者のai.syui.log.userに記録され、そのuser-listに基づいて、コメント一覧を取得します。 つまり、コメント表示のアカウントを手動で設定するか、自動化するか。自動化するならserverでailog stream serverを動かさなければいけません。 ask-AI ask-AIの仕組みは割愛します。後に変更される可能性が高いと思います。 local llm, mcp, atprotoと組み合わせです。 code syntax # comment d=${0:a:h} // This is a comment fn main() { println!(&quot;Hello, world!&quot;); } // This is a comment console.log(&quot;Hello, world!&quot;);",
"description": "rustで静的サイトジェネレータを作りました。ailogといいます。hugoからの移行になります。 \nailogは、最初にatproto-comment-system(oauth)とask-AIという機能をつけました。 \nquick start\n$ git clone https://git.syui.ai/ai/log\n$ cd log\n$ cargo build\n$ ./target/debu...",
"formated_time": "Thu Jun 12, 2025",
"href": "https://syui.ai/posts/2025-06-06-ailog.html",
"tags": [
"blog",
"rust",
"mcp",
"atp"
],
"title": "静的サイトジェネレータを作った",
"utc_time": "2025-06-12T00:00:00Z"
}
]

281
my-blog/static/js/ask-ai.js Normal file
View File

@@ -0,0 +1,281 @@
/**
* Ask AI functionality - Based on original working implementation
*/
// Global variables for AI functionality
let aiProfileData = null;
// Original functions from working implementation
function toggleAskAI() {
const panel = document.getElementById('askAiPanel');
const isVisible = panel.style.display !== 'none';
panel.style.display = isVisible ? 'none' : 'block';
if (!isVisible) {
checkAuthenticationStatus();
}
}
function checkAuthenticationStatus() {
const userSections = document.querySelectorAll('.user-section');
const isAuthenticated = userSections.length > 0;
if (isAuthenticated) {
// User is authenticated - show Ask AI UI
document.getElementById('authCheck').style.display = 'none';
document.getElementById('chatForm').style.display = 'block';
document.getElementById('chatHistory').style.display = 'block';
// Show initial greeting if chat history is empty
const chatHistory = document.getElementById('chatHistory');
if (chatHistory.children.length === 0) {
showInitialGreeting();
}
// Focus on input
setTimeout(() => {
document.getElementById('aiQuestion').focus();
}, 50);
} else {
// User not authenticated - show auth message
document.getElementById('authCheck').style.display = 'block';
document.getElementById('chatForm').style.display = 'none';
document.getElementById('chatHistory').style.display = 'none';
}
}
function askQuestion() {
const question = document.getElementById('aiQuestion').value;
if (!question.trim()) return;
const askButton = document.getElementById('askButton');
askButton.disabled = true;
askButton.textContent = 'Posting...';
try {
// Add user message to chat
addUserMessage(question);
// Clear input
document.getElementById('aiQuestion').value = '';
// Show loading
showLoadingMessage();
// Post question via OAuth app
window.dispatchEvent(new CustomEvent('postAIQuestion', {
detail: { question: question }
}));
} catch (error) {
console.error('Failed to ask question:', error);
showErrorMessage('Sorry, I encountered an error. Please try again.');
} finally {
askButton.disabled = false;
askButton.textContent = 'Ask';
}
}
function addUserMessage(question) {
const chatHistory = document.getElementById('chatHistory');
const userSection = document.querySelector('.user-section');
let userAvatar = '👤';
let userDisplay = 'You';
let userHandle = 'user';
if (userSection) {
const avatarImg = userSection.querySelector('.user-avatar');
const displayName = userSection.querySelector('.user-display-name');
const handle = userSection.querySelector('.user-handle');
if (avatarImg && avatarImg.src) {
userAvatar = `<img src="${avatarImg.src}" alt="${displayName?.textContent || 'User'}" class="profile-avatar">`;
}
if (displayName?.textContent) {
userDisplay = displayName.textContent;
}
if (handle?.textContent) {
userHandle = handle.textContent.replace('@', '');
}
}
const questionDiv = document.createElement('div');
questionDiv.className = 'chat-message user-message comment-style';
questionDiv.innerHTML = `
<div class="message-header">
<div class="avatar">${userAvatar}</div>
<div class="user-info">
<div class="display-name">${userDisplay}</div>
<div class="handle">@${userHandle}</div>
<div class="timestamp">${new Date().toLocaleString()}</div>
</div>
</div>
<div class="message-content">${question}</div>
`;
chatHistory.appendChild(questionDiv);
}
function showLoadingMessage() {
const chatHistory = document.getElementById('chatHistory');
const loadingDiv = document.createElement('div');
loadingDiv.className = 'ai-loading-simple';
loadingDiv.innerHTML = `
<i class="fas fa-robot"></i>
<span>考えています</span>
<i class="fas fa-spinner fa-spin"></i>
`;
chatHistory.appendChild(loadingDiv);
}
function showErrorMessage(message) {
const chatHistory = document.getElementById('chatHistory');
removeLoadingMessage();
const errorDiv = document.createElement('div');
errorDiv.className = 'chat-message error-message comment-style';
errorDiv.innerHTML = `
<div class="message-header">
<div class="avatar">⚠️</div>
<div class="user-info">
<div class="display-name">System</div>
<div class="handle">@system</div>
<div class="timestamp">${new Date().toLocaleString()}</div>
</div>
</div>
<div class="message-content">${message}</div>
`;
chatHistory.appendChild(errorDiv);
}
function removeLoadingMessage() {
const loadingMsg = document.querySelector('.ai-loading-simple');
if (loadingMsg) {
loadingMsg.remove();
}
}
function showInitialGreeting() {
if (!aiProfileData) return;
const chatHistory = document.getElementById('chatHistory');
const greetingDiv = document.createElement('div');
greetingDiv.className = 'chat-message ai-message comment-style initial-greeting';
const avatarElement = aiProfileData.avatar
? `<img src="${aiProfileData.avatar}" alt="${aiProfileData.displayName}" class="profile-avatar">`
: '🤖';
greetingDiv.innerHTML = `
<div class="message-header">
<div class="avatar">${avatarElement}</div>
<div class="user-info">
<div class="display-name">${aiProfileData.displayName}</div>
<div class="handle">@${aiProfileData.handle}</div>
<div class="timestamp">${new Date().toLocaleString()}</div>
</div>
</div>
<div class="message-content">
Hello! I'm an AI assistant trained on this blog's content. I can answer questions about the articles, provide insights, and help you understand the topics discussed here. What would you like to know?
</div>
`;
chatHistory.appendChild(greetingDiv);
}
function updateAskAIButton() {
const button = document.getElementById('askAiButton');
if (!button) return;
// Only update text, never modify the icon
if (aiProfileData && aiProfileData.displayName) {
const textNode = button.childNodes[2] || button.lastChild;
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
textNode.textContent = aiProfileData.displayName;
}
}
}
function handleAIResponse(responseData) {
const chatHistory = document.getElementById('chatHistory');
removeLoadingMessage();
const aiProfile = responseData.aiProfile;
if (!aiProfile || !aiProfile.handle || !aiProfile.displayName) {
console.error('AI profile data is missing');
return;
}
const timestamp = new Date(responseData.timestamp || Date.now());
const avatarElement = aiProfile.avatar
? `<img src="${aiProfile.avatar}" alt="${aiProfile.displayName}" class="profile-avatar">`
: '🤖';
const answerDiv = document.createElement('div');
answerDiv.className = 'chat-message ai-message comment-style';
answerDiv.innerHTML = `
<div class="message-header">
<div class="avatar">${avatarElement}</div>
<div class="user-info">
<div class="display-name">${aiProfile.displayName}</div>
<div class="handle">@${aiProfile.handle}</div>
<div class="timestamp">${timestamp.toLocaleString()}</div>
</div>
</div>
<div class="message-content">${responseData.answer}</div>
`;
chatHistory.appendChild(answerDiv);
// Limit chat history
limitChatHistory();
}
function limitChatHistory() {
const chatHistory = document.getElementById('chatHistory');
if (chatHistory.children.length > 10) {
chatHistory.removeChild(chatHistory.children[0]);
if (chatHistory.children.length > 0) {
chatHistory.removeChild(chatHistory.children[0]);
}
}
}
// Event listeners setup
function setupAskAIEventListeners() {
// Listen for AI profile updates from OAuth app
window.addEventListener('aiProfileLoaded', function(event) {
aiProfileData = event.detail;
console.log('AI profile loaded:', aiProfileData);
updateAskAIButton();
});
// Listen for AI responses
window.addEventListener('aiResponseReceived', function(event) {
handleAIResponse(event.detail);
});
// Keyboard shortcuts
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
const panel = document.getElementById('askAiPanel');
if (panel) {
panel.style.display = 'none';
}
}
// Enter key to send message (only when not composing Japanese input)
if (e.key === 'Enter' && e.target.id === 'aiQuestion' && !e.shiftKey && !e.isComposing) {
e.preventDefault();
askQuestion();
}
});
}
// Initialize Ask AI when DOM is loaded
document.addEventListener('DOMContentLoaded', function() {
setupAskAIEventListeners();
console.log('Ask AI initialized successfully');
});
// Global functions for onclick handlers
window.toggleAskAI = toggleAskAI;
window.askQuestion = askQuestion;

View File

@@ -0,0 +1,94 @@
/**
* Theme and visual effects - Pure CSS animations, no jQuery
*/
class Theme {
constructor() {
this.init();
}
init() {
this.setupParticleColors();
this.setupLogoAnimations();
}
setupParticleColors() {
// Dynamic particle colors based on theme
const style = document.createElement('style');
style.textContent = `
/* Dynamic particle colors based on theme */
.likeButton .particleLayer circle:nth-child(1),
.likeButton .particleLayer circle:nth-child(2) {
fill: var(--particle-color-1) !important;
}
.likeButton .particleLayer circle:nth-child(3),
.likeButton .particleLayer circle:nth-child(4) {
fill: var(--particle-color-2) !important;
}
.likeButton .particleLayer circle:nth-child(5),
.likeButton .particleLayer circle:nth-child(6),
.likeButton .particleLayer circle:nth-child(7) {
fill: var(--particle-color-3) !important;
}
.likeButton .particleLayer circle:nth-child(8),
.likeButton .particleLayer circle:nth-child(9),
.likeButton .particleLayer circle:nth-child(10) {
fill: var(--particle-color-4) !important;
}
.likeButton .particleLayer circle:nth-child(11),
.likeButton .particleLayer circle:nth-child(12),
.likeButton .particleLayer circle:nth-child(13),
.likeButton .particleLayer circle:nth-child(14) {
fill: var(--particle-color-5) !important;
}
/* Reset initial animations but allow hover */
.likeButton .syui {
animation: none;
}
.likeButton .particleLayer {
animation: none;
}
.likeButton .explosion {
animation: none;
}
/* Enable hover animations from package */
.likeButton:hover .syui,
.likeButton:hover path.syui {
animation: syuiDeluxeAnime 400ms forwards !important;
}
.likeButton:hover .particleLayer {
animation: particleLayerAnime 800ms forwards !important;
}
.likeButton:hover .explosion {
animation: explosionAnime 800ms forwards !important;
}
/* Logo positioning */
.logo .likeButton {
background: transparent !important;
display: block;
}
`;
document.head.appendChild(style);
}
setupLogoAnimations() {
// Pure CSS animations are handled by the svg-animation-package.css
// This method is reserved for any future JavaScript-based enhancements
console.log('Logo animations initialized (CSS-based)');
}
}
// Initialize theme when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
new Theme();
});

View File

@@ -0,0 +1,165 @@
Fonticons, Inc. (https://fontawesome.com)
--------------------------------------------------------------------------------
Font Awesome Free License
Font Awesome Free is free, open source, and GPL friendly. You can use it for
commercial projects, open source projects, or really almost whatever you want.
Full Font Awesome Free license: https://fontawesome.com/license/free.
--------------------------------------------------------------------------------
# Icons: CC BY 4.0 License (https://creativecommons.org/licenses/by/4.0/)
The Font Awesome Free download is licensed under a Creative Commons
Attribution 4.0 International License and applies to all icons packaged
as SVG and JS file types.
--------------------------------------------------------------------------------
# Fonts: SIL OFL 1.1 License
In the Font Awesome Free download, the SIL OFL license applies to all icons
packaged as web and desktop font files.
Copyright (c) 2024 Fonticons, Inc. (https://fontawesome.com)
with Reserved Font Name: "Font Awesome".
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
SIL OPEN FONT LICENSE
Version 1.1 - 26 February 2007
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting — in part or in whole — any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
--------------------------------------------------------------------------------
# Code: MIT License (https://opensource.org/licenses/MIT)
In the Font Awesome Free download, the MIT license applies to all non-font and
non-icon files.
Copyright 2024 Fonticons, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in the
Software without restriction, including without limitation the rights to use, copy,
modify, merge, publish, distribute, sublicense, and/or sell copies of the Software,
and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--------------------------------------------------------------------------------
# Attribution
Attribution is required by MIT, SIL OFL, and CC BY licenses. Downloaded Font
Awesome Free files already contain embedded comments with sufficient
attribution, so you shouldn't need to do anything additional when using these
files normally.
We've kept attribution comments terse, so we ask that you do not actively work
to remove them from files, especially code. They're a great way for folks to
learn about Font Awesome.
--------------------------------------------------------------------------------
# Brand Icons
All brand icons are trademarks of their respective owners. The use of these
trademarks does not indicate endorsement of the trademark holder by Font
Awesome, nor vice versa. **Please do not use brand logos for any purpose except
to represent the company, product, or service to which they refer.**

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,6 @@
/*!
* Font Awesome Free 6.5.2 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
* Copyright 2024 Fonticons, Inc.
*/
:host,:root{--fa-style-family-classic:"Font Awesome 6 Free";--fa-font-regular:normal 400 1em/1 "Font Awesome 6 Free"}@font-face{font-family:"Font Awesome 6 Free";font-style:normal;font-weight:400;font-display:block;src:url(../webfonts/fa-regular-400.woff2) format("woff2"),url(../webfonts/fa-regular-400.ttf) format("truetype")}.fa-regular,.far{font-weight:400}

View File

@@ -0,0 +1,6 @@
/*!
* Font Awesome Free 6.5.2 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
* Copyright 2024 Fonticons, Inc.
*/
:host,:root{--fa-style-family-classic:"Font Awesome 6 Free";--fa-font-solid:normal 900 1em/1 "Font Awesome 6 Free"}@font-face{font-family:"Font Awesome 6 Free";font-style:normal;font-weight:900;font-display:block;src:url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.ttf) format("truetype")}.fa-solid,.fas{font-weight:900}

Binary file not shown.

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,99 @@
@font-face {
font-family: 'icomoon';
src: url('fonts/icomoon.eot?mxezzh');
src: url('fonts/icomoon.eot?mxezzh#iefix') format('embedded-opentype'),
url('fonts/icomoon.ttf?mxezzh') format('truetype'),
url('fonts/icomoon.woff?mxezzh') format('woff'),
url('fonts/icomoon.svg?mxezzh#icomoon') format('svg');
font-weight: normal;
font-style: normal;
font-display: block;
}
[class^="icon-"], [class*=" icon-"] {
/* use !important to prevent issues with browser extensions that change fonts */
font-family: 'icomoon' !important;
speak: never;
font-style: normal;
font-weight: normal;
font-variant: normal;
text-transform: none;
line-height: 1;
/* Better Font Rendering =========== */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.icon-git:before {
content: "\e901";
}
.icon-cube:before {
content: "\e900";
}
.icon-game:before {
content: "\e9d5";
}
.icon-card:before {
content: "\e9d6";
}
.icon-book:before {
content: "\e9d7";
}
.icon-git1:before {
content: "\e9d3";
}
.icon-moji_a:before {
content: "\e9c3";
}
.icon-archlinux:before {
content: "\e9c4";
}
.icon-archlinuxjp:before {
content: "\e9c5";
}
.icon-syui:before {
content: "\e9c6";
}
.icon-phoenix-power:before {
content: "\e9c7";
}
.icon-phoenix-world:before {
content: "\e9c8";
}
.icon-power:before {
content: "\e9c9";
}
.icon-phoenix:before {
content: "\e9ca";
}
.icon-honeycomb:before {
content: "\e9cb";
}
.icon-ai:before {
content: "\e9cc";
}
.icon-robot:before {
content: "\e9cd";
}
.icon-sandar:before {
content: "\e9ce";
}
.icon-moon:before {
content: "\e9cf";
}
.icon-home:before {
content: "\e9d0";
}
.icon-cloud:before {
content: "\e9d1";
}
.icon-api:before {
content: "\e9d2";
}
.icon-aibadge:before {
content: "\ebf8";
}
.icon-aiterm:before {
content: "\ebf7";
}

24
my-blog/static/syui.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg width="77pt" height="77pt" viewBox="0 0 512 512" class="likeButton" >
<circle class="explosion" r="150" cx="250" cy="250"></circle>
<g class="particleLayer">
<circle fill="#ef454aba" cx="130" cy="126.5" r="12.5"/>
<circle fill="#ef454acc" cx="411" cy="313.5" r="12.5"/>
<circle fill="#ef454aba" cx="279" cy="86.5" r="12.5"/>
<circle fill="#ef454aba" cx="155" cy="390.5" r="12.5"/>
<circle fill="#ef454aba" cx="89" cy="292.5" r="10.5"/>
<circle fill="#ef454aba" cx="414" cy="282.5" r="10.5"/>
<circle fill="#ef454a91" cx="115" cy="149.5" r="10.5"/>
<circle fill="#ef454aba" cx="250" cy="80.5" r="10.5"/>
<circle fill="#ef454aba" cx="78" cy="261.5" r="10.5"/>
<circle fill="#ef454a91" cx="182" cy="402.5" r="10.5"/>
<circle fill="#ef454aba" cx="401.5" cy="166" r="13"/>
<circle fill="#ef454aba" cx="379" cy="141.5" r="10.5"/>
<circle fill="#ef454a91" cx="327" cy="397.5" r="10.5"/>
<circle fill="#ef454aba" cx="296" cy="392.5" r="10.5"/>
</g>
<g transform="translate(0,512) scale(0.1,-0.1)" fill="#000000" class="icon_syui">
<path class="syui" d="M3660 4460 c-11 -11 -33 -47 -48 -80 l-29 -60 -12 38 c-27 88 -58 92 -98 11 -35 -70 -73 -159 -73 -169 0 -6 -5 -10 -10 -10 -6 0 -15 -10 -21 -22 -33 -73 -52 -92 -47 -48 2 26 -1 35 -14 38 -16 3 -168 -121 -168 -138 0 -5 -13 -16 -28 -24 -24 -13 -35 -12 -87 0 -221 55 -231 56 -480 56 -219 1 -247 -1 -320 -22 -44 -12 -96 -26 -115 -30 -57 -13 -122 -39 -200 -82 -8 -4 -31 -14 -50 -23 -41 -17 -34 -13 -146 -90 -87 -59 -292 -252 -351 -330 -63 -83 -143 -209 -143 -225 0 -10 -7 -23 -15 -30 -8 -7 -15 -17 -15 -22 0 -5 -13 -37 -28 -71 -16 -34 -36 -93 -45 -132 -9 -38 -24 -104 -34 -145 -13 -60 -17 -121 -17 -300 1 -224 1 -225 36 -365 24 -94 53 -175 87 -247 28 -58 51 -108 51 -112 0 -3 13 -24 28 -48 42 -63 46 -79 22 -85 -11 -3 -20 -9 -20 -14 0 -5 -4 -9 -10 -9 -5 0 -22 -11 -37 -25 -16 -13 -75 -59 -133 -100 -58 -42 -113 -82 -123 -90 -9 -8 -22 -15 -27 -15 -6 0 -10 -6 -10 -13 0 -8 -11 -20 -25 -27 -34 -18 -34 -54 0 -48 14 3 25 2 25 -1 0 -3 -43 -31 -95 -61 -52 -30 -95 -58 -95 -62 0 -5 -5 -8 -11 -8 -19 0 -84 -33 -92 -47 -4 -7 -15 -13 -22 -13 -14 0 -17 -4 -19 -32 -1 -8 15 -15 37 -18 l38 -5 -47 -48 c-56 -59 -54 -81 9 -75 30 3 45 0 54 -11 9 -13 16 -14 43 -4 29 11 30 10 18 -5 -7 -9 -19 -23 -25 -30 -7 -7 -13 -20 -13 -29 0 -12 8 -14 38 -9 20 4 57 8 82 9 25 2 54 8 66 15 18 10 23 8 32 -13 17 -38 86 -35 152 6 27 17 50 34 50 38 0 16 62 30 85 19 33 -15 72 -2 89 30 8 15 31 43 51 62 35 34 38 35 118 35 77 0 85 2 126 33 24 17 52 32 61 32 9 0 42 18 73 40 30 22 61 40 69 40 21 0 88 -26 100 -38 7 -7 17 -12 24 -12 7 0 35 -11 62 -25 66 -33 263 -84 387 -101 189 -25 372 -12 574 41 106 27 130 37 261 97 41 20 80 37 85 39 6 2 51 31 100 64 166 111 405 372 489 534 10 20 22 43 27 51 5 8 12 22 15 30 3 8 17 40 31 70 54 115 95 313 108 520 13 200 -43 480 -134 672 -28 58 -51 108 -51 112 0 3 -13 24 -29 48 -15 24 -34 60 -40 80 -19 57 3 142 50 193 10 11 22 49 28 85 6 36 16 67 21 68 18 6 31 53 25 83 -4 18 -17 33 -36 41 -16 7 -29 15 -29 18 1 10 38 50 47 50 5 0 20 11 33 25 18 19 22 31 17 61 -3 20 -14 45 -23 55 -16 18 -16 20 6 44 15 16 21 32 18 49 -3 15 1 34 8 43 32 43 7 73 -46 55 l-30 -11 0 85 c0 74 -2 84 -18 84 -21 0 -53 -33 -103 -104 l-34 -48 -5 74 c-7 102 -35 133 -80 88z m-870 -740 c36 -7 75 -14 88 -16 21 -4 23 -9 16 -37 -3 -18 -14 -43 -24 -57 -10 -14 -20 -35 -24 -46 -4 -12 -16 -32 -27 -45 -12 -13 -37 -49 -56 -79 -20 -30 -52 -73 -72 -96 -53 -60 -114 -133 -156 -189 -21 -27 -44 -54 -52 -58 -7 -4 -13 -14 -13 -22 0 -7 -18 -33 -40 -57 -22 -23 -40 -46 -40 -50 0 -5 -19 -21 -42 -38 -47 -35 -85 -38 -188 -15 -115 25 -173 20 -264 -23 -45 -22 -106 -46 -136 -56 -48 -15 -77 -25 -140 -50 -70 -28 -100 -77 -51 -84 14 -2 34 -10 45 -17 12 -7 53 -16 91 -20 90 -9 131 -22 178 -57 20 -16 52 -35 70 -43 18 -7 40 -22 49 -32 16 -18 15 -22 -24 -88 -23 -39 -47 -74 -53 -80 -7 -5 -23 -26 -36 -45 -26 -39 -92 -113 -207 -232 -4 -4 -37 -36 -73 -71 l-66 -64 -20 41 c-58 119 -105 240 -115 301 -40 244 -35 409 20 595 8 30 21 66 28 80 7 14 24 54 38 89 15 35 35 75 46 89 11 13 20 31 20 38 0 8 3 14 8 14 4 0 16 16 27 36 24 45 221 245 278 281 23 15 44 30 47 33 20 20 138 78 250 123 61 24 167 50 250 61 60 7 302 -1 370 -14z m837 -661 c52 -101 102 -279 106 -379 2 -42 0 -45 -28 -51 -16 -4 -101 -7 -187 -8 -166 -1 -229 10 -271 49 -19 19 -19 19 14 49 22 21 44 31 65 31 41 0 84 34 84 66 0 30 12 55 56 112 19 25 37 65 44 95 11 51 53 111 74 104 6 -2 25 -32 43 -68z m-662 -810 c17 -10 40 -24 53 -30 12 -7 22 -16 22 -20 0 -4 17 -13 38 -19 20 -7 44 -18 52 -24 8 -7 33 -21 55 -31 22 -11 42 -23 45 -26 11 -14 109 -49 164 -58 62 -11 101 -7 126 14 15 14 38 18 78 16 39 -2 26 -41 -49 -146 -78 -109 -85 -118 -186 -219 -61 -61 -239 -189 -281 -203 -17 -5 -73 -29 -104 -44 -187 -92 -605 -103 -791 -21 -42 19 -47 24 -37 41 5 11 28 32 51 48 22 15 51 38 64 51 13 12 28 22 33 22 17 0 242 233 242 250 0 6 5 10 10 10 6 0 10 6 10 14 0 25 50 55 100 62 59 8 56 6 115 83 50 66 74 117 75 162 0 14 7 40 16 57 18 38 52 41 99 11z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

@@ -0,0 +1,97 @@
<!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>
<!-- Favicon -->
<link rel="icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<!-- Stylesheets -->
<link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="/css/svg-animation-package.css">
<link rel="stylesheet" href="/pkg/icomoon/style.css">
<link rel="stylesheet" href="/pkg/font-awesome/css/all.min.css">
{% block head %}{% endblock %}
</head>
<body>
<div class="container">
<header class="main-header">
<div class="header-content">
<h1><a href="/" class="site-title">{{ config.title }}</a></h1>
<div class="logo">
<a href="/">
<svg width="77pt" height="77pt" viewBox="0 0 512 512" class="likeButton">
<circle class="explosion" r="150" cx="250" cy="250"></circle>
<g class="particleLayer">
<circle fill="#8CE8C3" cx="130" cy="126.5" r="12.5"></circle>
<circle fill="#8CE8C3" cx="411" cy="313.5" r="12.5"></circle>
<circle fill="#91D2FA" cx="279" cy="86.5" r="12.5"></circle>
<circle fill="#91D2FA" cx="155" cy="390.5" r="12.5"></circle>
<circle fill="#CC8EF5" cx="89" cy="292.5" r="10.5"></circle>
<circle fill="#9BDFBA" cx="414" cy="282.5" r="10.5"></circle>
<circle fill="#9BDFBA" cx="115" cy="149.5" r="10.5"></circle>
<circle fill="#9FC7FA" cx="250" cy="80.5" r="10.5"></circle>
<circle fill="#9FC7FA" cx="78" cy="261.5" r="10.5"></circle>
<circle fill="#96D8E9" cx="182" cy="402.5" r="10.5"></circle>
<circle fill="#CC8EF5" cx="401.5" cy="166" r="13"></circle>
<circle fill="#DB92D0" cx="379" cy="141.5" r="10.5"></circle>
<circle fill="#DB92D0" cx="327" cy="397.5" r="10.5"></circle>
<circle fill="#DD99B8" cx="296" cy="392.5" r="10.5"></circle>
</g>
<g transform="translate(0,512) scale(0.1,-0.1)" fill="#000000" class="icon_syui">
<path class="syui" d="M3660 4460 c-11 -11 -33 -47 -48 -80 l-29 -60 -12 38 c-27 88 -58 92 -98 11 -35 -70 -73 -159 -73 -169 0 -6 -5 -10 -10 -10 -6 0 -15 -10 -21 -22 -33 -73 -52 -92 -47 -48 2 26 -1 35 -14 38 -16 3 -168 -121 -168 -138 0 -5 -13 -16 -28 -24 -24 -13 -35 -12 -87 0 -221 55 -231 56 -480 56 -219 1 -247 -1 -320 -22 -44 -12 -96 -26 -115 -30 -57 -13 -122 -39 -200 -82 -8 -4 -31 -14 -50 -23 -41 -17 -34 -13 -146 -90 -87 -59 -292 -252 -351 -330 -63 -83 -143 -209 -143 -225 0 -10 -7 -23 -15 -30 -8 -7 -15 -17 -15 -22 0 -5 -13 -37 -28 -71 -16 -34 -36 -93 -45 -132 -9 -38 -24 -104 -34 -145 -13 -60 -17 -121 -17 -300 1 -224 1 -225 36 -365 24 -94 53 -175 87 -247 28 -58 51 -108 51 -112 0 -3 13 -24 28 -48 42 -63 46 -79 22 -85 -11 -3 -20 -9 -20 -14 0 -5 -4 -9 -10 -9 -5 0 -22 -11 -37 -25 -16 -13 -75 -59 -133 -100 -58 -42 -113 -82 -123 -90 -9 -8 -22 -15 -27 -15 -6 0 -10 -6 -10 -13 0 -8 -11 -20 -25 -27 -34 -18 -34 -54 0 -48 14 3 25 2 25 -1 0 -3 -43 -31 -95 -61 -52 -30 -95 -58 -95 -62 0 -5 -5 -8 -11 -8 -19 0 -84 -33 -92 -47 -4 -7 -15 -13 -22 -13 -14 0 -17 -4 -19 -32 -1 -8 15 -15 37 -18 l38 -5 -47 -48 c-56 -59 -54 -81 9 -75 30 3 45 0 54 -11 9 -13 16 -14 43 -4 29 11 30 10 18 -5 -7 -9 -19 -23 -25 -30 -7 -7 -13 -20 -13 -29 0 -12 8 -14 38 -9 20 4 57 8 82 9 25 2 54 8 66 15 18 10 23 8 32 -13 17 -38 86 -35 152 6 27 17 50 34 50 38 0 16 62 30 85 19 33 -15 72 -2 89 30 8 15 31 43 51 62 35 34 38 35 118 35 77 0 85 2 126 33 24 17 52 32 61 32 9 0 42 18 73 40 30 22 61 40 69 40 21 0 88 -26 100 -38 7 -7 17 -12 24 -12 7 0 35 -11 62 -25 66 -33 263 -84 387 -101 189 -25 372 -12 574 41 106 27 130 37 261 97 41 20 80 37 85 39 6 2 51 31 100 64 166 111 405 372 489 534 10 20 22 43 27 51 5 8 12 22 15 30 3 8 17 40 31 70 54 115 95 313 108 520 13 200 -43 480 -134 672 -28 58 -51 108 -51 112 0 3 -13 24 -29 48 -15 24 -34 60 -40 80 -19 57 3 142 50 193 10 11 22 49 28 85 6 36 16 67 21 68 18 6 31 53 25 83 -4 18 -17 33 -36 41 -16 7 -29 15 -29 18 1 10 38 50 47 50 5 0 20 11 33 25 18 19 22 31 17 61 -3 20 -14 45 -23 55 -16 18 -16 20 6 44 15 16 21 32 18 49 -3 15 1 34 8 43 32 43 7 73 -46 55 l-30 -11 0 85 c0 74 -2 84 -18 84 -21 0 -53 -33 -103 -104 l-34 -48 -5 74 c-7 102 -35 133 -80 88z m-870 -740 c36 -7 75 -14 88 -16 21 -4 23 -9 16 -37 -3 -18 -14 -43 -24 -57 -10 -14 -20 -35 -24 -46 -4 -12 -16 -32 -27 -45 -12 -13 -37 -49 -56 -79 -20 -30 -52 -73 -72 -96 -53 -60 -114 -133 -156 -189 -21 -27 -44 -54 -52 -58 -7 -4 -13 -14 -13 -22 0 -7 -18 -33 -40 -57 -22 -23 -40 -46 -40 -50 0 -5 -19 -21 -42 -38 -47 -35 -85 -38 -188 -15 -115 25 -173 20 -264 -23 -45 -22 -106 -46 -136 -56 -48 -15 -77 -25 -140 -50 -70 -28 -100 -77 -51 -84 14 -2 34 -10 45 -17 12 -7 53 -16 91 -20 90 -9 131 -22 178 -57 20 -16 52 -35 70 -43 18 -7 40 -22 49 -32 16 -18 15 -22 -24 -88 -23 -39 -47 -74 -53 -80 -7 -5 -23 -26 -36 -45 -26 -39 -92 -113 -207 -232 -4 -4 -37 -36 -73 -71 l-66 -64 -20 41 c-58 119 -105 240 -115 301 -40 244 -35 409 20 595 8 30 21 66 28 80 7 14 24 54 38 89 15 35 35 75 46 89 11 13 20 31 20 38 0 8 3 14 8 14 4 0 16 16 27 36 24 45 221 245 278 281 23 15 44 30 47 33 20 20 138 78 250 123 61 24 167 50 250 61 60 7 302 -1 370 -14z m837 -661 c52 -101 102 -279 106 -379 2 -42 0 -45 -28 -51 -16 -4 -101 -7 -187 -8 -166 -1 -229 10 -271 49 -19 19 -19 19 14 49 22 21 44 31 65 31 41 0 84 34 84 66 0 30 12 55 56 112 19 25 37 65 44 95 11 51 53 111 74 104 6 -2 25 -32 43 -68z m-662 -810 c17 -10 40 -24 53 -30 12 -7 22 -16 22 -20 0 -4 17 -13 38 -19 20 -7 44 -18 52 -24 8 -7 33 -21 55 -31 22 -11 42 -23 45 -26 11 -14 109 -49 164 -58 62 -11 101 -7 126 14 15 14 38 18 78 16 39 -2 26 -41 -49 -146 -78 -109 -85 -118 -186 -219 -61 -61 -239 -189 -281 -203 -17 -5 -73 -29 -104 -44 -187 -92 -605 -103 -791 -21 -42 19 -47 24 -37 41 5 11 28 32 51 48 22 15 51 38 64 51 13 12 28 22 33 22 17 0 242 233 242 250 0 6 5 10 10 10 6 0 10 6 10 14 0 25 50 55 100 62 59 8 56 6 115 83 50 66 74 117 75 162 0 14 7 40 16 57 18 38 52 41 99 11z"></path>
</g>
</svg>
</a>
</div>
<div class="header-actions">
<button class="ask-ai-btn" onclick="toggleAskAI()" id="askAiButton">
<span class="ai-icon icon-ai"></span>
ai
</button>
</div>
</div>
</header>
<!-- Ask AI Panel -->
<div class="ask-ai-panel" id="askAiPanel" style="display: none;">
<div class="ask-ai-content">
<div id="authCheck" class="auth-check">
<p>🔒 Please login with ATProto to use Ask AI feature</p>
</div>
<div id="chatForm" class="ask-ai-form" style="display: none;">
<input type="text" id="aiQuestion" placeholder="What would you like to know?" />
<button onclick="askQuestion()" id="askButton">Ask</button>
</div>
<div id="chatHistory" class="chat-history" style="display: none;"></div>
</div>
</div>
<main class="main-content">
{% block content %}{% endblock %}
</main>
{% block sidebar %}{% endblock %}
</div>
<footer class="main-footer">
<div class="footer-social">
<a href="https://web.syu.is/@syui" target="_blank"><i class="fab fa-bluesky"></i></a>
<a href="https://git.syui.ai/ai" target="_blank"><span class="icon-ai"></span></a>
<a href="https://git.syui.ai/syui" target="_blank"><span class="icon-git"></span></a>
</div>
<p>© {{ config.author }}</p>
</footer>
<script src="/js/ask-ai.js"></script>
<script src="/js/theme.js"></script>
{% include "oauth-assets.html" %}
</body>
</html>

View File

@@ -0,0 +1,52 @@
{% extends "base.html" %}
{% block content %}
<div class="timeline-container">
<div class="timeline-feed">
{% for post in posts %}
<article class="timeline-post">
<div class="post-header">
<div class="post-meta">
<time class="post-date">{{ post.date }}</time>
{% if post.language %}
<span class="post-lang">{{ post.language }}</span>
{% endif %}
</div>
</div>
<div class="post-content">
<h3 class="post-title">
<a href="{{ post.url }}">{{ post.title }}</a>
</h3>
{% if post.excerpt %}
<p class="post-excerpt">{{ post.excerpt }}</p>
{% endif %}
<div class="post-actions">
<a href="{{ post.url }}" class="read-more">Read more</a>
{% if post.markdown_url %}
<a href="{{ post.markdown_url }}" class="view-markdown" title="View Markdown">.md</a>
{% endif %}
{% if post.translation_url %}
<a href="{{ post.translation_url }}" class="view-translation" title="View Translation">🌐</a>
{% endif %}
</div>
</div>
</article>
{% endfor %}
</div>
<!-- OAuth Comment System -->
<section class="comment-section">
<div id="comment-atproto"></div>
</section>
{% if posts|length == 0 %}
<div class="empty-state">
<p>No posts yet. Start writing!</p>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,3 @@
<!-- OAuth Comment System - Load globally for session management -->
<script type="module" crossorigin src="/assets/comment-atproto-mfW-OeY_.js"></script>
<link rel="stylesheet" crossorigin href="/assets/comment-atproto-Cm5qR-aM.css">

View File

@@ -0,0 +1,71 @@
<!-- OAuth authentication widget for ailog -->
<div id="oauth-widget">
<div id="status" class="status">
Login with your Bluesky account
</div>
<!-- Login form -->
<div id="login-form">
<input type="text" id="handle-input" placeholder="Enter your handle (e.g., user.bsky.social)" style="width: 300px; padding: 10px; margin: 10px 0; border: 1px solid #ddd; border-radius: 4px;">
<br>
<button id="login-btn">🦋 Login with Bluesky</button>
</div>
<!-- Authenticated state -->
<div id="authenticated-state" style="display: none;">
<div id="user-info"></div>
<button id="logout-btn">Logout</button>
<button id="test-profile-btn">Get Profile</button>
</div>
<div id="console-log" class="log"></div>
</div>
<script src="/oauth-widget-simple.js"></script>
<style>
.status {
margin: 20px 0;
padding: 15px;
border-radius: 8px;
background: #f5f5f5;
}
.user-info {
background: #e8f5e8;
border: 1px solid #4caf50;
}
.error {
background: #ffeaea;
border: 1px solid #f44336;
color: #d32f2f;
}
#oauth-widget button {
background: #1185fe;
color: white;
border: none;
padding: 12px 24px;
border-radius: 6px;
font-size: 16px;
cursor: pointer;
margin: 10px;
}
#oauth-widget button:hover {
background: #0d6efd;
}
#oauth-widget button:disabled {
background: #6c757d;
cursor: not-allowed;
}
.log {
text-align: left;
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 6px;
padding: 15px;
margin: 20px 0;
max-height: 300px;
overflow-y: auto;
font-family: monospace;
font-size: 14px;
}
</style>

View File

@@ -0,0 +1,373 @@
{% extends "base.html" %}
{% block title %}{{ post.title }} - {{ config.title }}{% endblock %}
{% block content %}
<div class="article-container">
<article class="article-content">
<header class="article-header">
<h1 class="article-title">{{ post.title }}</h1>
<div class="article-meta">
<time class="article-date">{{ post.date }}</time>
{% if post.language %}
<span class="article-lang">{{ post.language }}</span>
{% endif %}
</div>
<div class="article-actions">
{% if post.markdown_url %}
<a href="{{ post.markdown_url }}" class="action-btn markdown-btn" title="View Markdown">
.md
</a>
{% endif %}
{% if post.translation_url %}
<a href="{{ post.translation_url }}" class="action-btn translation-btn" title="View Translation">
🌐 {% if post.language == 'ja' %}English{% else %}日本語{% endif %}
</a>
{% endif %}
</div>
</header>
<div class="article-body">
{{ post.content | safe }}
</div>
<!-- Comment Section -->
<section class="comment-section">
<div class="comment-container">
<h3>Comments</h3>
<!-- ATProto Auth Widget Container -->
<div id="atproto-auth-widget" class="comment-auth"></div>
<div id="commentForm" class="comment-form" style="display: none;">
<textarea id="commentText" placeholder="Share your thoughts..." rows="4"></textarea>
<button onclick="submitComment()" class="submit-btn">Post Comment</button>
</div>
<div id="commentsList" class="comments-list">
<!-- Comments will be loaded here -->
</div>
</div>
</section>
</article>
<aside class="article-sidebar">
<nav class="toc">
<h3>Contents</h3>
<div id="toc-content">
<!-- TOC will be generated by JavaScript -->
</div>
</nav>
</aside>
</div>
{% endblock %}
{% block sidebar %}
<!-- Include ATProto Libraries via script tags (more reliable than dynamic imports) -->
<script src="https://cdn.jsdelivr.net/npm/@atproto/oauth-client-browser@latest/dist/index.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@atproto/api@latest/dist/index.js"></script>
<!-- Fallback: Try multiple CDNs -->
<script>
console.log('Checking ATProto library availability...');
// Check if libraries loaded successfully
if (typeof ATProto === 'undefined' && typeof window.ATProto === 'undefined') {
console.log('Primary CDN failed, trying fallback...');
// Create fallback script elements
const fallbackScripts = [
'https://unpkg.com/@atproto/oauth-client-browser@latest/dist/index.js',
'https://esm.sh/@atproto/oauth-client-browser',
'https://cdn.skypack.dev/@atproto/oauth-client-browser'
];
// Load fallback scripts sequentially
let scriptIndex = 0;
function loadNextScript() {
if (scriptIndex < fallbackScripts.length) {
const script = document.createElement('script');
script.src = fallbackScripts[scriptIndex];
script.onload = () => {
console.log(`Loaded from fallback CDN: ${fallbackScripts[scriptIndex]}`);
window.atprotoLibrariesReady = true;
};
script.onerror = () => {
console.log(`Failed to load from: ${fallbackScripts[scriptIndex]}`);
scriptIndex++;
loadNextScript();
};
document.head.appendChild(script);
} else {
console.error('All CDN fallbacks failed');
window.atprotoLibrariesReady = false;
}
}
loadNextScript();
} else {
console.log('✅ ATProto libraries loaded from primary CDN');
window.atprotoLibrariesReady = true;
}
</script>
<!-- Simple ATProto Widget (no external dependency) -->
<link rel="stylesheet" href="/atproto-auth-widget/dist/atproto-auth.min.css">
<script>
// Initialize auth widget
let authWidget = null;
document.addEventListener('DOMContentLoaded', function() {
generateTableOfContents();
initializeAuthWidget();
loadComments();
});
function generateTableOfContents() {
const tocContainer = document.getElementById('toc-content');
const headings = document.querySelectorAll('.article-body h1, .article-body h2, .article-body h3, .article-body h4, .article-body h5, .article-body h6');
if (headings.length === 0) {
tocContainer.innerHTML = '<p class="no-toc">No headings found</p>';
return;
}
const tocList = document.createElement('ul');
tocList.className = 'toc-list';
headings.forEach((heading, index) => {
const id = `heading-${index}`;
heading.id = id;
const listItem = document.createElement('li');
listItem.className = `toc-item toc-${heading.tagName.toLowerCase()}`;
const link = document.createElement('a');
link.href = `#${id}`;
link.textContent = heading.textContent;
link.className = 'toc-link';
// Smooth scroll behavior
link.addEventListener('click', function(e) {
e.preventDefault();
heading.scrollIntoView({ behavior: 'smooth' });
});
listItem.appendChild(link);
tocList.appendChild(listItem);
});
tocContainer.appendChild(tocList);
}
// Initialize ATProto Auth Widget
async function initializeAuthWidget() {
try {
// Check WebCrypto API availability
console.log('WebCrypto check:', {
available: !!window.crypto && !!window.crypto.subtle,
secureContext: window.isSecureContext,
protocol: window.location.protocol,
hostname: window.location.hostname
});
if (!window.crypto || !window.crypto.subtle) {
throw new Error('WebCrypto API is not available. This requires HTTPS or localhost.');
}
if (!window.isSecureContext) {
console.warn('Not in secure context - WebCrypto may not work properly');
}
// Simplified approach: Show manual OAuth form
console.log('Using simplified OAuth approach...');
showSimpleOAuthForm();
// Fallback to widget initialization
authWidget = await window.initATProtoWidget('#atproto-auth-widget', {
clientId: clientId,
onLogin: (session) => {
console.log('User logged in:', session.handle);
document.getElementById('commentForm').style.display = 'block';
},
onLogout: () => {
console.log('User logged out');
document.getElementById('commentForm').style.display = 'none';
},
onError: (error) => {
console.error('ATProto Auth Error:', error);
// Show user-friendly error message
const authContainer = document.getElementById('atproto-auth-widget');
if (authContainer) {
let errorMessage = 'Authentication service is temporarily unavailable.';
let suggestion = 'Please try refreshing the page.';
if (error.message && error.message.includes('WebCrypto')) {
errorMessage = 'This feature requires a secure HTTPS connection.';
suggestion = 'Please ensure you are accessing via https://log.syui.ai';
}
authContainer.innerHTML = `
<div class="atproto-auth__fallback">
<p>${errorMessage}</p>
<p>${suggestion}</p>
<details style="margin-top: 10px; font-size: 0.8em; color: #666;">
<summary>Technical details</summary>
<pre>${error.message || 'Unknown error'}</pre>
</details>
</div>
`;
}
},
theme: 'default'
});
} else if (typeof window.ATProtoAuthWidget === 'function') {
// Fallback to direct widget initialization
authWidget = new window.ATProtoAuthWidget({
containerSelector: '#atproto-auth-widget',
clientId: clientId,
onLogin: (session) => {
console.log('User logged in:', session.handle);
document.getElementById('commentForm').style.display = 'block';
},
onLogout: () => {
console.log('User logged out');
document.getElementById('commentForm').style.display = 'none';
},
onError: (error) => {
console.error('ATProto Auth Error:', error);
const authContainer = document.getElementById('atproto-auth-widget');
if (authContainer) {
authContainer.innerHTML = `
<div class="atproto-auth__fallback">
<p>Authentication service is temporarily unavailable.</p>
<p>Please try refreshing the page.</p>
</div>
`;
}
},
theme: 'default'
});
await authWidget.init();
} else {
throw new Error('ATProto widget not available');
}
} catch (error) {
console.error('Failed to initialize auth widget:', error);
// Show fallback UI
const authContainer = document.getElementById('atproto-auth-widget');
if (authContainer) {
authContainer.innerHTML = `
<div class="atproto-auth__fallback">
<p>Authentication widget failed to load.</p>
<p>Please check your internet connection and refresh the page.</p>
</div>
`;
}
}
}
async function submitComment() {
const commentText = document.getElementById('commentText').value.trim();
if (!commentText || !authWidget.isLoggedIn()) {
alert('Please login and enter a comment');
return;
}
try {
const postSlug = '{{ post.slug }}';
const postUrl = window.location.href;
const createdAt = new Date().toISOString();
// Create comment record using the auth widget
const response = await authWidget.createRecord('ai.log.comment', {
$type: 'ai.log.comment',
text: commentText,
post_slug: postSlug,
post_url: postUrl,
createdAt: createdAt
});
console.log('Comment posted:', response);
document.getElementById('commentText').value = '';
loadComments();
} catch (error) {
console.error('Comment submission failed:', error);
alert('Failed to post comment: ' + error.message);
}
}
function showAuthenticatedState(session) {
const authContainer = document.getElementById('atproto-auth-widget');
const agent = new window.ATProtoAgent(session);
authContainer.innerHTML = `
<div class="atproto-auth__authenticated">
<p>✅ Authenticated as: <strong>${session.did}</strong></p>
<button id="logout-btn" class="atproto-auth__button">Logout</button>
</div>
`;
document.getElementById('logout-btn').onclick = async () => {
await session.signOut();
window.location.reload();
};
// Show comment form
document.getElementById('commentForm').style.display = 'block';
window.currentSession = session;
window.currentAgent = agent;
}
function showLoginForm(oauthClient) {
const authContainer = document.getElementById('atproto-auth-widget');
authContainer.innerHTML = `
<div class="atproto-auth__login">
<h4>Login with ATProto</h4>
<input type="text" id="handle-input" placeholder="user.bsky.social" />
<button id="login-btn" class="atproto-auth__button">Connect</button>
</div>
`;
document.getElementById('login-btn').onclick = async () => {
const handle = document.getElementById('handle-input').value.trim();
if (!handle) {
alert('Please enter your handle');
return;
}
try {
const url = await oauthClient.authorize(handle);
window.open(url, '_self', 'noopener');
} catch (error) {
console.error('OAuth authorization failed:', error);
alert('Authentication failed: ' + error.message);
}
};
// Enter key support
document.getElementById('handle-input').onkeypress = (e) => {
if (e.key === 'Enter') {
document.getElementById('login-btn').click();
}
};
}
async function loadComments() {
try {
const commentsList = document.getElementById('commentsList');
commentsList.innerHTML = '<p class="loading">Loading comments from ATProto network...</p>';
// In a real implementation, you would query an aggregation service
// For demo, show empty state
setTimeout(() => {
commentsList.innerHTML = '<p class="no-comments">Comments will appear here when posted via ATProto.</p>';
}, 1000);
} catch (error) {
console.error('Failed to load comments:', error);
document.getElementById('commentsList').innerHTML = '<p class="error">Failed to load comments</p>';
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,196 @@
{% extends "base.html" %}
{% block title %}{{ post.title }} - {{ config.title }}{% endblock %}
{% block content %}
<div class="article-container">
<article class="article-content">
<header class="article-header">
<h1 class="article-title">{{ post.title }}</h1>
<div class="article-meta">
<time class="article-date">{{ post.date }}</time>
{% if post.language %}
<span class="article-lang">{{ post.language }}</span>
{% endif %}
</div>
<div class="article-actions">
{% if post.markdown_url %}
<a href="{{ post.markdown_url }}" class="action-btn markdown-btn" title="View Markdown">
.md
</a>
{% endif %}
{% if post.translation_url %}
<a href="{{ post.translation_url }}" class="action-btn translation-btn" title="View Translation">
🌐 {% if post.language == 'ja' %}English{% else %}日本語{% endif %}
</a>
{% endif %}
</div>
</header>
<div class="article-body">
{{ post.content | safe }}
</div>
<!-- Simple Comment Section -->
<section class="comment-section">
<div class="comment-container">
<h3>Comments</h3>
<!-- Simple OAuth Button -->
<div class="simple-oauth">
<p>📝 To comment, authenticate with Bluesky:</p>
<button id="bluesky-auth" class="oauth-button">
🦋 Login with Bluesky
</button>
<p class="oauth-note">
<small>After authentication, you can post comments that will be stored in your ATProto PDS.</small>
</p>
</div>
<div id="comments-list" class="comments-list">
<p class="no-comments">Comments will appear here when posted via ATProto.</p>
</div>
</div>
</section>
</article>
<aside class="article-sidebar">
<nav class="toc">
<h3>Contents</h3>
<div id="toc-content">
<!-- TOC will be generated by JavaScript -->
</div>
</nav>
</aside>
</div>
{% endblock %}
{% block sidebar %}
<script>
document.addEventListener('DOMContentLoaded', function() {
generateTableOfContents();
initializeSimpleAuth();
});
function generateTableOfContents() {
const tocContainer = document.getElementById('toc-content');
const headings = document.querySelectorAll('.article-body h1, .article-body h2, .article-body h3, .article-body h4, .article-body h5, .article-body h6');
if (headings.length === 0) {
tocContainer.innerHTML = '<p class="no-toc">No headings found</p>';
return;
}
const tocList = document.createElement('ul');
tocList.className = 'toc-list';
headings.forEach((heading, index) => {
const id = `heading-${index}`;
heading.id = id;
const listItem = document.createElement('li');
listItem.className = `toc-item toc-${heading.tagName.toLowerCase()}`;
const link = document.createElement('a');
link.href = `#${id}`;
link.textContent = heading.textContent;
link.className = 'toc-link';
link.addEventListener('click', function(e) {
e.preventDefault();
heading.scrollIntoView({ behavior: 'smooth' });
});
listItem.appendChild(link);
tocList.appendChild(listItem);
});
tocContainer.appendChild(tocList);
}
function initializeSimpleAuth() {
const authButton = document.getElementById('bluesky-auth');
authButton.addEventListener('click', function() {
// Simple approach: Direct redirect to Bluesky OAuth
const isProduction = window.location.hostname === 'log.syui.ai';
const clientId = isProduction
? 'https://log.syui.ai/client-metadata.json'
: window.location.origin + '/client-metadata.json';
const authUrl = `https://bsky.social/oauth/authorize?` +
`client_id=${encodeURIComponent(clientId)}&` +
`redirect_uri=${encodeURIComponent(window.location.href)}&` +
`response_type=code&` +
`scope=atproto%20transition:generic&` +
`state=demo-state`;
console.log('Redirecting to:', authUrl);
// Open in new tab for now (safer for testing)
window.open(authUrl, '_blank');
// Show status message
authButton.innerHTML = '✅ Check the new tab for authentication';
authButton.disabled = true;
});
// Check if we're returning from OAuth
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('code')) {
console.log('OAuth callback detected:', urlParams.get('code'));
document.querySelector('.simple-oauth').innerHTML = `
<div class="oauth-success">
✅ OAuth callback received!<br>
<small>Code: ${urlParams.get('code')}</small><br>
<small>In a full implementation, this would exchange the code for tokens.</small>
</div>
`;
}
}
</script>
<style>
.simple-oauth {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 20px;
margin: 20px 0;
text-align: center;
}
.oauth-button {
background: #1185fe;
color: white;
border: none;
padding: 12px 24px;
border-radius: 6px;
font-size: 16px;
cursor: pointer;
margin: 10px 0;
}
.oauth-button:hover {
background: #0d6efd;
}
.oauth-button:disabled {
background: #6c757d;
cursor: not-allowed;
}
.oauth-note {
color: #6c757d;
font-style: italic;
}
.oauth-success {
background: #d1edff;
border: 1px solid #b6d7ff;
border-radius: 4px;
padding: 15px;
color: #0c5460;
}
</style>
{% endblock %}

View File

@@ -0,0 +1,93 @@
{% extends "base.html" %}
{% block title %}{{ post.title }} - {{ config.title }}{% endblock %}
{% block content %}
<div class="article-container">
<article class="article-content">
<header class="article-header">
<h1 class="article-title">{{ post.title }}</h1>
<div class="article-meta">
<time class="article-date">{{ post.date }}</time>
{% if post.language %}
<span class="article-lang">{{ post.language }}</span>
{% endif %}
</div>
<div class="article-actions">
{% if post.markdown_url %}
<a href="{{ post.markdown_url }}" class="action-btn markdown-btn" title="View Markdown">
.md
</a>
{% endif %}
{% if post.translation_url %}
<a href="{{ post.translation_url }}" class="action-btn translation-btn" title="View Translation">
🌐 {% if post.language == 'ja' %}English{% else %}日本語{% endif %}
</a>
{% endif %}
</div>
</header>
<div class="article-body">
{{ post.content | safe }}
</div>
<div id="comment-atproto"></div>
</article>
<aside class="article-sidebar">
<nav class="toc">
<h3>Contents</h3>
<div id="toc-content">
<!-- TOC will be generated by JavaScript -->
</div>
</nav>
</aside>
</div>
<script>
// Generate table of contents
function generateTableOfContents() {
const tocContainer = document.getElementById('toc-content');
const headings = document.querySelectorAll('.article-body h1, .article-body h2, .article-body h3, .article-body h4, .article-body h5, .article-body h6');
if (headings.length === 0) {
tocContainer.innerHTML = '<p class="no-toc">No headings found</p>';
return;
}
const tocList = document.createElement('ul');
tocList.className = 'toc-list';
headings.forEach((heading, index) => {
const id = `heading-${index}`;
heading.id = id;
const listItem = document.createElement('li');
listItem.className = `toc-item toc-${heading.tagName.toLowerCase()}`;
const link = document.createElement('a');
link.href = `#${id}`;
link.textContent = heading.textContent;
link.className = 'toc-link';
link.addEventListener('click', function(e) {
e.preventDefault();
heading.scrollIntoView({ behavior: 'smooth' });
});
listItem.appendChild(link);
tocList.appendChild(listItem);
});
tocContainer.appendChild(tocList);
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', () => {
generateTableOfContents();
});
</script>
{% endblock %}
{% block sidebar %}
{% endblock %}

21
oauth/.env.production Normal file
View File

@@ -0,0 +1,21 @@
# Production environment variables
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
# Base collection (all others are derived via getCollectionNames)
VITE_OAUTH_COLLECTION=ai.syui.log
# AI Configuration
VITE_AI_ENABLED=true
VITE_AI_ASK_AI=true
VITE_AI_PROVIDER=ollama
VITE_AI_MODEL=gemma3:4b
VITE_AI_HOST=https://ollama.syui.ai
VITE_AI_SYSTEM_PROMPT="あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"
VITE_AI_DID=did:plc:4hqjfn7m6n5hno3doamuhgef
# API Configuration
VITE_BSKY_PUBLIC_API=https://public.api.bsky.app
VITE_ATPROTO_API=https://bsky.social

View File

@@ -0,0 +1,14 @@
{
"keys": [
{
"kty": "EC",
"crv": "P-256",
"x": "mock_x_coordinate_base64url",
"y": "mock_y_coordinate_base64url",
"d": "mock_private_key_base64url",
"use": "sig",
"kid": "ai-card-oauth-key-1",
"alg": "ES256"
}
]
}

View File

@@ -1,7 +1,16 @@
/* Theme Colors */
:root {
--theme-color: #FF4500;
--white: #fff;
--light-gray: #aaa;
--dark-gray: #666;
--background: #fff;
}
.app {
min-height: 100vh;
background: linear-gradient(180deg, #f8f9fa 0%, #ffffff 100%);
color: #333333;
background: linear-gradient(180deg, #f8f9fa 0%, var(--background) 100%);
color: var(--dark-gray);
}
.app-header {
@@ -41,15 +50,15 @@
}
.nav-button.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: 1px solid #667eea;
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4);
background: var(--theme-color);
color: var(--white);
border: 1px solid var(--theme-color);
box-shadow: 0 4px 16px rgba(255, 69, 0, 0.4);
}
.nav-button.active:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.5);
box-shadow: 0 6px 20px rgba(255, 69, 0, 0.5);
}
.app-header h1 {
@@ -99,9 +108,9 @@
}
.login-button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: 1px solid #667eea;
background: var(--theme-color);
color: var(--white);
border: 1px solid var(--theme-color);
}
.backup-button {
@@ -124,7 +133,7 @@
.login-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
box-shadow: 0 4px 12px rgba(255, 69, 0, 0.4);
}
.backup-button:hover {
@@ -153,11 +162,68 @@
}
.app-main {
max-width: 1200px;
max-width: 1000px;
margin: 0 auto;
padding: 40px 20px;
}
@media (max-width: 1000px) {
.app .app-main {
padding: 0px !important;
}
.comment-item {
padding: 0px !important;
margin: 0px !important;
}
.auth-section {
padding: 0px !important;
}
.comments-list {
padding: 0px !important;
}
.comment-section {
padding: 0px !important;
margin: 0px !important;
}
.comment-content {
padding: 10px !important;
word-wrap: break-word !important;
overflow-wrap: break-word !important;
white-space: pre-wrap !important;
}
.comment-header {
padding: 10px !important;
}
/* Fix overflow on article pages */
article.article-content {
overflow-x: hidden !important;
}
/* Ensure full width on mobile */
.app {
max-width: 100vw !important;
}
/* Fix button overflow */
button {
max-width: 100%;
white-space: normal;
}
/* Fix comment-meta URI overflow */
.comment-meta {
word-break: break-all !important;
overflow-wrap: break-word !important;
}
}
.gacha-section {
text-align: center;
margin-bottom: 60px;
@@ -255,9 +321,10 @@
.comment-section {
max-width: 800px;
margin: 0 auto;
padding: 20px;
/* padding: 20px; - removed to avoid double padding */
}
.auth-section {
background: #f8f9fa;
border: 1px solid #e9ecef;
@@ -268,8 +335,8 @@
}
.atproto-button {
background: #1185fe;
color: white;
background: var(--theme-color);
color: var(--white);
border: none;
padding: 12px 24px;
border-radius: 6px;
@@ -281,9 +348,9 @@
}
.atproto-button:hover {
background: #0d6efd;
filter: brightness(1.1);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(17, 133, 254, 0.4);
box-shadow: 0 4px 12px rgba(255, 69, 0, 0.4);
}
.username-input-section {
@@ -407,8 +474,8 @@
}
.post-button {
background: #28a745;
color: white;
background: var(--theme-color);
color: var(--white);
border: none;
padding: 10px 20px;
border-radius: 6px;
@@ -419,9 +486,9 @@
}
.post-button:hover:not(:disabled) {
background: #218838;
filter: brightness(1.1);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(40, 167, 69, 0.4);
box-shadow: 0 4px 12px rgba(255, 69, 0, 0.4);
}
.post-button:disabled {
@@ -432,9 +499,8 @@
}
.comments-list {
border: 1px solid #ddd;
border-radius: 8px;
padding: 20px;
padding: 0px;
}
.comments-header {
@@ -455,8 +521,8 @@
}
.comments-toggle-button {
background: #1185fe;
color: white;
background: var(--theme-color);
color: var(--white);
border: none;
padding: 8px 16px;
border-radius: 6px;
@@ -467,9 +533,9 @@
}
.comments-toggle-button:hover {
background: #0d6efd;
filter: brightness(1.1);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(17, 133, 254, 0.4);
box-shadow: 0 4px 12px rgba(255, 69, 0, 0.4);
}
.comment-item {
@@ -524,10 +590,12 @@
}
.delete-button {
background: none;
background: #dc3545;
color: white;
border: none;
cursor: pointer;
font-size: 16px;
font-size: 12px;
font-weight: 500;
padding: 4px 8px;
border-radius: 4px;
transition: all 0.3s ease;
@@ -535,14 +603,16 @@
}
.delete-button:hover {
background: rgba(220, 53, 69, 0.1);
transform: scale(1.1);
background: #c82333;
transform: scale(1.05);
}
.comment-content {
line-height: 1.5;
color: #333;
margin-bottom: 10px;
white-space: pre-wrap;
word-wrap: break-word;
}
.comment-meta {
@@ -712,8 +782,8 @@
/* JSON Display Styles */
.json-button {
background: #4caf50;
color: white;
background: var(--theme-color);
color: var(--white);
border: none;
padding: 4px 8px;
border-radius: 4px;
@@ -724,7 +794,7 @@
}
.json-button:hover {
background: #45a049;
filter: brightness(1.1);
transform: scale(1.05);
}
@@ -757,4 +827,90 @@
color: #333;
max-height: 400px;
overflow-y: auto;
}
/* Tab Navigation */
.tab-navigation {
display: flex;
border-bottom: 2px solid #e1e5e9;
margin-bottom: 20px;
}
.tab-button {
background: none;
border: none;
padding: 12px 20px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
color: #656d76;
border-bottom: 2px solid transparent;
transition: all 0.2s;
}
.tab-button:hover {
color: var(--theme-color);
background: #f6f8fa;
}
.tab-button.active {
color: var(--theme-color);
border-bottom-color: var(--theme-color);
background: #f6f8fa;
}
.chat-actions {
display: flex;
align-items: center;
gap: 8px;
}
.chat-type-button {
background: var(--theme-color);
color: var(--white);
border: none;
padding: 4px 8px;
border-radius: 4px;
cursor: default;
font-size: 12px;
font-weight: 500;
margin-left: 4px;
}
.chat-type-text {
font-size: 16px;
margin-left: 4px;
}
.chat-date {
color: #656d76;
font-size: 12px;
}
.chat-content {
background: #f6f8fa;
padding: 12px;
border-radius: 6px;
border-left: 4px solid #d1d9e0;
margin-bottom: 8px;
white-space: pre-wrap;
line-height: 1.5;
}
.chat-meta {
font-size: 11px;
color: #656d76;
}
.no-chat {
text-align: center;
padding: 40px 20px;
color: #656d76;
font-style: italic;
}
.chat-message.comment-style {
border-left: 4px solid var(--theme-color);
}

1538
oauth/src/App.tsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
// Cloudflare Access対応版の例
const response = await fetch(`${aiConfig.host}/api/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// Cloudflare Access Service Token
'CF-Access-Client-Id': import.meta.env.VITE_CF_ACCESS_CLIENT_ID,
'CF-Access-Client-Secret': import.meta.env.VITE_CF_ACCESS_CLIENT_SECRET,
},
body: JSON.stringify({
model: aiConfig.model,
prompt: prompt,
stream: false,
options: {
temperature: 0.9,
top_p: 0.9,
num_predict: 80,
repeat_penalty: 1.1,
}
}),
});

View File

@@ -0,0 +1,270 @@
import React, { useState, useEffect } from 'react';
import { User } from '../services/auth';
import { atprotoOAuthService } from '../services/atproto-oauth';
import { appConfig, getCollectionNames } from '../config/app';
interface AIChatProps {
user: User | null;
isEnabled: boolean;
}
export const AIChat: React.FC<AIChatProps> = ({ user, isEnabled }) => {
const [chatHistory, setChatHistory] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [aiProfile, setAiProfile] = useState<any>(null);
// Get AI settings from appConfig (unified configuration)
const aiConfig = {
enabled: appConfig.aiEnabled,
askAi: appConfig.aiAskAi,
provider: appConfig.aiProvider,
model: appConfig.aiModel,
host: appConfig.aiHost,
systemPrompt: appConfig.aiSystemPrompt,
aiDid: appConfig.aiDid,
bskyPublicApi: appConfig.bskyPublicApi,
};
// Fetch AI profile on load
useEffect(() => {
const fetchAIProfile = async () => {
if (!aiConfig.aiDid) {
return;
}
try {
// Try with agent first
const agent = atprotoOAuthService.getAgent();
if (agent) {
const profile = await agent.getProfile({ actor: aiConfig.aiDid });
const profileData = {
did: aiConfig.aiDid,
handle: profile.data.handle,
displayName: profile.data.displayName,
avatar: profile.data.avatar,
description: profile.data.description
};
setAiProfile(profileData);
// Dispatch event to update Ask AI button
window.dispatchEvent(new CustomEvent('aiProfileLoaded', { detail: profileData }));
return;
}
// Fallback to public API
const response = await fetch(`${aiConfig.bskyPublicApi}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(aiConfig.aiDid)}`);
if (response.ok) {
const profileData = await response.json();
const profile = {
did: aiConfig.aiDid,
handle: profileData.handle,
displayName: profileData.displayName,
avatar: profileData.avatar,
description: profileData.description
};
setAiProfile(profile);
// Dispatch event to update Ask AI button
window.dispatchEvent(new CustomEvent('aiProfileLoaded', { detail: profile }));
return;
}
} catch (error) {
setAiProfile(null);
}
};
fetchAIProfile();
}, [aiConfig.aiDid]);
useEffect(() => {
if (!isEnabled || !aiConfig.askAi) return;
// Listen for AI question posts from base.html
const handleAIQuestion = async (event: any) => {
if (!user || !event.detail || !event.detail.question || isProcessing || !aiProfile) return;
setIsProcessing(true);
try {
await postQuestionAndGenerateResponse(event.detail.question);
} finally {
setIsProcessing(false);
}
};
// Add listener with a small delay to ensure it's ready
setTimeout(() => {
window.addEventListener('postAIQuestion', handleAIQuestion);
// Notify that AI is ready
window.dispatchEvent(new CustomEvent('aiChatReady'));
}, 100);
return () => {
window.removeEventListener('postAIQuestion', handleAIQuestion);
};
}, [user, isEnabled, isProcessing, aiProfile]);
const postQuestionAndGenerateResponse = async (question: string) => {
if (!user || !aiConfig.askAi || !aiProfile) return;
setIsLoading(true);
try {
const agent = atprotoOAuthService.getAgent();
if (!agent) throw new Error('No agent available');
// Get collection names
const collections = getCollectionNames(appConfig.collections.base);
// 1. Post question to ATProto
const now = new Date();
const rkey = now.toISOString().replace(/[:.]/g, '-');
// Extract post metadata from current page
const currentUrl = window.location.href;
const postSlug = currentUrl.match(/\/posts\/([^/]+)/)?.[1] || '';
const postTitle = document.title.replace(' - syui.ai', '') || '';
const questionRecord = {
$type: collections.chat,
post: {
url: currentUrl,
slug: postSlug,
title: postTitle,
date: new Date().toISOString(),
tags: [],
language: "ja"
},
type: "question",
text: question,
author: {
did: user.did,
handle: user.handle,
avatar: user.avatar,
displayName: user.displayName || user.handle,
},
createdAt: now.toISOString(),
};
await agent.api.com.atproto.repo.putRecord({
repo: user.did,
collection: collections.chat,
rkey: rkey,
record: questionRecord,
});
// 2. Get chat history
const chatRecords = await agent.api.com.atproto.repo.listRecords({
repo: user.did,
collection: collections.chat,
limit: 10,
});
let chatHistoryText = '';
if (chatRecords.data.records) {
chatHistoryText = chatRecords.data.records
.map((r: any) => {
if (r.value.type === 'question') {
return `User: ${r.value.text}`;
} else if (r.value.type === 'answer') {
return `AI: ${r.value.text}`;
}
return '';
})
.filter(Boolean)
.join('\n');
}
// 3. Generate AI response based on provider
let aiAnswer = '';
// 3. Generate AI response using Ollama via proxy
if (aiConfig.provider === 'ollama') {
const prompt = `${aiConfig.systemPrompt}
Question: ${question}
Answer:`;
const response = await fetch(`${aiConfig.host}/api/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: aiConfig.model,
prompt: prompt,
stream: false,
options: {
temperature: 0.9,
top_p: 0.9,
num_predict: 80, // Shorter responses for faster generation
repeat_penalty: 1.1,
}
}),
});
if (!response.ok) {
throw new Error('AI API request failed');
}
const data = await response.json();
aiAnswer = data.response;
}
// 4. Immediately dispatch event to update UI
window.dispatchEvent(new CustomEvent('aiResponseReceived', {
detail: {
answer: aiAnswer,
aiProfile: aiProfile,
timestamp: now.toISOString()
}
}));
// 5. Save AI response in background
const answerRkey = now.toISOString().replace(/[:.]/g, '-') + '-answer';
const answerRecord = {
$type: collections.chat,
post: {
url: currentUrl,
slug: postSlug,
title: postTitle,
date: new Date().toISOString(),
tags: [],
language: "ja"
},
type: "answer",
text: aiAnswer,
author: {
did: aiProfile.did,
handle: aiProfile.handle,
displayName: aiProfile.displayName,
avatar: aiProfile.avatar,
},
createdAt: now.toISOString(),
};
// Save to ATProto asynchronously (don't wait for it)
agent.api.com.atproto.repo.putRecord({
repo: user.did,
collection: collections.chat,
rkey: answerRkey,
record: answerRecord,
}).catch(err => {
// Silent fail for AI response saving
});
} catch (error) {
window.dispatchEvent(new CustomEvent('aiResponseError', {
detail: { error: 'AI応答の生成に失敗しました' }
}));
} finally {
setIsLoading(false);
}
};
// This component doesn't render anything - it just handles the logic
return null;
};

View File

@@ -0,0 +1,79 @@
import React, { useState, useEffect } from 'react';
import { AtprotoAgent } from '@atproto/api';
interface AIProfile {
did: string;
handle: string;
displayName?: string;
avatar?: string;
description?: string;
}
interface AIProfileProps {
aiDid: string;
}
export const AIProfile: React.FC<AIProfileProps> = ({ aiDid }) => {
const [profile, setProfile] = useState<AIProfile | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchAIProfile = async () => {
try {
// Use public API to get profile information
const agent = new AtprotoAgent({ service: 'https://bsky.social' });
const response = await agent.getProfile({ actor: aiDid });
setProfile({
did: response.data.did,
handle: response.data.handle,
displayName: response.data.displayName,
avatar: response.data.avatar,
description: response.data.description,
});
} catch (error) {
console.error('Failed to fetch AI profile:', error);
// Fallback to basic info
setProfile({
did: aiDid,
handle: 'ai-assistant',
displayName: 'AI Assistant',
description: 'AI assistant for this blog',
});
} finally {
setLoading(false);
}
};
if (aiDid) {
fetchAIProfile();
}
}, [aiDid]);
if (loading) {
return <div className="ai-profile-loading">Loading AI profile...</div>;
}
if (!profile) {
return null;
}
return (
<div className="ai-profile">
<div className="ai-avatar">
{profile.avatar ? (
<img src={profile.avatar} alt={profile.displayName || profile.handle} />
) : (
<div className="ai-avatar-placeholder">🤖</div>
)}
</div>
<div className="ai-info">
<div className="ai-name">{profile.displayName || profile.handle}</div>
<div className="ai-handle">@{profile.handle}</div>
{profile.description && (
<div className="ai-description">{profile.description}</div>
)}
</div>
</div>
);
};

126
oauth/src/config/app.ts Normal file
View File

@@ -0,0 +1,126 @@
// Application configuration
export interface AppConfig {
adminDid: string;
aiDid: string;
collections: {
base: string; // Base collection like "ai.syui.log"
};
host: string;
rkey?: string; // Current post rkey if on post page
aiEnabled: boolean;
aiAskAi: boolean;
aiProvider: string;
aiModel: string;
aiHost: string;
aiSystemPrompt: string;
bskyPublicApi: string;
atprotoApi: string;
}
// Collection name builders (similar to Rust implementation)
export function getCollectionNames(base: string) {
if (!base) {
// Fallback to default
base = 'ai.syui.log';
}
const collections = {
comment: base,
user: `${base}.user`,
chat: `${base}.chat`,
chatLang: `${base}.chat.lang`,
chatComment: `${base}.chat.comment`,
};
return collections;
}
// Generate collection names from host
// Format: ${reg}.${name}.${sub}
// Example: log.syui.ai -> ai.syui.log
function generateBaseCollectionFromHost(host: string): string {
try {
// Remove protocol if present
const cleanHost = host.replace(/^https?:\/\//, '');
// Split host into parts
const parts = cleanHost.split('.');
if (parts.length < 2) {
throw new Error('Invalid host format');
}
// Reverse the parts for collection naming
// log.syui.ai -> ai.syui.log
const reversedParts = parts.reverse();
const result = reversedParts.join('.');
return result;
} catch (error) {
// Fallback to default
return 'ai.syui.log';
}
}
// Extract rkey from current URL
// /posts/xxx -> xxx (remove .html if present)
function extractRkeyFromUrl(): string | undefined {
const pathname = window.location.pathname;
const match = pathname.match(/\/posts\/([^/]+)\/?$/);
if (match) {
// Remove .html extension if present
return match[1].replace(/\.html$/, '');
}
return undefined;
}
// Get application configuration from environment variables
export function getAppConfig(): AppConfig {
const host = import.meta.env.VITE_APP_HOST || 'https://log.syui.ai';
const adminDid = import.meta.env.VITE_ADMIN_DID || 'did:plc:uqzpqmrjnptsxezjx4xuh2mn';
const aiDid = import.meta.env.VITE_AI_DID || 'did:plc:4hqjfn7m6n5hno3doamuhgef';
// Priority: Environment variables > Auto-generated from host
const autoGeneratedBase = generateBaseCollectionFromHost(host);
let baseCollection = import.meta.env.VITE_OAUTH_COLLECTION || autoGeneratedBase;
// Ensure base collection is never undefined
if (!baseCollection) {
baseCollection = 'ai.syui.log';
}
const collections = {
base: baseCollection,
};
const rkey = extractRkeyFromUrl();
// AI configuration
const aiEnabled = import.meta.env.VITE_AI_ENABLED === 'true';
const aiAskAi = import.meta.env.VITE_AI_ASK_AI === 'true';
const aiProvider = import.meta.env.VITE_AI_PROVIDER || 'ollama';
const aiModel = import.meta.env.VITE_AI_MODEL || 'gemma2:2b';
const aiHost = import.meta.env.VITE_AI_HOST || 'https://ollama.syui.ai';
const aiSystemPrompt = import.meta.env.VITE_AI_SYSTEM_PROMPT || 'You are a helpful AI assistant trained on this blog\'s content.';
const bskyPublicApi = import.meta.env.VITE_BSKY_PUBLIC_API || 'https://public.api.bsky.app';
const atprotoApi = import.meta.env.VITE_ATPROTO_API || 'https://bsky.social';
return {
adminDid,
aiDid,
collections,
host,
rkey,
aiEnabled,
aiAskAi,
aiProvider,
aiModel,
aiHost,
aiSystemPrompt,
bskyPublicApi,
atprotoApi
};
}
// Export singleton instance
export const appConfig = getAppConfig();

30
oauth/src/main.tsx Normal file
View File

@@ -0,0 +1,30 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import App from './App'
import { OAuthCallbackPage } from './components/OAuthCallbackPage'
import { CardList } from './components/CardList'
import { OAuthEndpointHandler } from './utils/oauth-endpoints'
// Initialize OAuth endpoint handlers for dynamic client metadata and JWKS
// DISABLED: This may interfere with BrowserOAuthClient
// OAuthEndpointHandler.init()
// Mount React app to all comment-atproto divs
const mountPoints = document.querySelectorAll('#comment-atproto');
console.log(`Found ${mountPoints.length} comment-atproto mount points`);
mountPoints.forEach((mountPoint, index) => {
console.log(`Mounting React app to comment-atproto #${index + 1}`);
ReactDOM.createRoot(mountPoint as HTMLElement).render(
<React.StrictMode>
<BrowserRouter>
<Routes>
<Route path="/oauth/callback" element={<OAuthCallbackPage />} />
<Route path="/list" element={<CardList />} />
<Route path="*" element={<App />} />
</Routes>
</BrowserRouter>
</React.StrictMode>,
);
});

View File

@@ -73,7 +73,6 @@ export const aiCardApi = {
});
return response.data.data;
} catch (error) {
console.warn('ai.gpt AI分析機能が利用できません:', error);
throw new Error('AI分析機能を利用するにはai.gptサーバーが必要です');
}
},
@@ -86,7 +85,6 @@ export const aiCardApi = {
const response = await aiGptApi.get('/card_get_gacha_stats');
return response.data.data;
} catch (error) {
console.warn('ai.gpt AI統計機能が利用できません:', error);
throw new Error('AI統計機能を利用するにはai.gptサーバーが必要です');
}
},

View File

@@ -31,11 +31,11 @@ class AtprotoOAuthService {
private async _doInitialize(): Promise<void> {
try {
console.log('=== INITIALIZING ATPROTO OAUTH CLIENT ===');
// Generate client ID based on current origin
const clientId = this.getClientId();
console.log('Client ID:', clientId);
// Support multiple PDS hosts for OAuth
this.oauthClient = await BrowserOAuthClient.load({
@@ -43,39 +43,33 @@ class AtprotoOAuthService {
handleResolver: 'https://bsky.social', // Default resolver
});
console.log('BrowserOAuthClient initialized successfully with multi-PDS support');
// Try to restore existing session
const result = await this.oauthClient.init();
if (result?.session) {
console.log('Existing session restored:', {
did: result.session.did,
handle: result.session.handle || 'unknown',
hasAccessJwt: !!result.session.accessJwt,
hasRefreshJwt: !!result.session.refreshJwt
});
// Create Agent instance with proper configuration
console.log('Creating Agent with session:', result.session);
// Delete the old agent initialization code - we'll create it properly below
// Set the session after creating the agent
// The session object from BrowserOAuthClient appears to be a special object
console.log('Full session object:', result.session);
console.log('Session type:', typeof result.session);
console.log('Session constructor:', result.session?.constructor?.name);
// Try to iterate over the session object
if (result.session) {
console.log('Session properties:');
for (const key in result.session) {
console.log(` ${key}:`, result.session[key]);
}
// Check if session has methods
const methods = Object.getOwnPropertyNames(Object.getPrototypeOf(result.session));
console.log('Session methods:', methods);
}
// BrowserOAuthClient might return a Session object that needs to be used with the agent
@@ -83,36 +77,36 @@ class AtprotoOAuthService {
if (result.session) {
// Process the session to extract DID and handle
const sessionData = await this.processSession(result.session);
console.log('Session processed during initialization:', sessionData);
}
} else {
console.log('No existing session found');
}
} catch (error) {
console.error('Failed to initialize OAuth client:', error);
this.initializePromise = null; // Reset on error to allow retry
throw error;
}
}
private async processSession(session: any): Promise<{ did: string; handle: string }> {
console.log('Processing session:', session);
// Log full session structure
console.log('Session structure:');
console.log('- sub:', session.sub);
console.log('- did:', session.did);
console.log('- handle:', session.handle);
console.log('- iss:', session.iss);
console.log('- aud:', session.aud);
// Check if agent has properties we can access
if (session.agent) {
console.log('- agent:', session.agent);
console.log('- agent.did:', session.agent?.did);
console.log('- agent.handle:', session.agent?.handle);
}
const did = session.sub || session.did;
@@ -121,18 +115,18 @@ class AtprotoOAuthService {
// Create Agent directly with session (per official docs)
try {
this.agent = new Agent(session);
console.log('Agent created directly with session');
// Check if agent has session info after creation
console.log('Agent after creation:');
console.log('- agent.did:', this.agent.did);
console.log('- agent.session:', this.agent.session);
if (this.agent.session) {
console.log('- agent.session.did:', this.agent.session.did);
console.log('- agent.session.handle:', this.agent.session.handle);
}
} catch (err) {
console.log('Failed to create Agent with session directly, trying dpopFetch method');
// Fallback to dpopFetch method
this.agent = new Agent({
service: session.server?.serviceEndpoint || 'https://bsky.social',
@@ -145,7 +139,7 @@ class AtprotoOAuthService {
// If handle is missing, try multiple methods to resolve it
if (!handle || handle === 'unknown') {
console.log('Handle not in session, attempting to resolve...');
// Method 1: Try using the agent to get profile
try {
@@ -154,11 +148,11 @@ class AtprotoOAuthService {
if (profile.data.handle) {
handle = profile.data.handle;
(this as any)._sessionInfo.handle = handle;
console.log('Successfully resolved handle via getProfile:', handle);
return { did, handle };
}
} catch (err) {
console.error('getProfile failed:', err);
}
// Method 2: Try using describeRepo
@@ -169,18 +163,20 @@ class AtprotoOAuthService {
if (repoDesc.data.handle) {
handle = repoDesc.data.handle;
(this as any)._sessionInfo.handle = handle;
console.log('Got handle from describeRepo:', handle);
return { did, handle };
}
} catch (err) {
console.error('describeRepo failed:', err);
}
// Method 3: Hardcoded fallback for known DIDs
if (did === 'did:plc:uqzpqmrjnptsxezjx4xuh2mn') {
handle = 'syui.ai';
// Method 3: Fallback for admin DID
const adminDid = import.meta.env.VITE_ADMIN_DID;
if (did === adminDid) {
const appHost = import.meta.env.VITE_APP_HOST || 'https://syui.ai';
handle = new URL(appHost).hostname;
(this as any)._sessionInfo.handle = handle;
console.log('Using hardcoded handle for known DID');
}
}
@@ -191,7 +187,7 @@ class AtprotoOAuthService {
// Use environment variable if available
const envClientId = import.meta.env.VITE_OAUTH_CLIENT_ID;
if (envClientId) {
console.log('Using client ID from environment:', envClientId);
return envClientId;
}
@@ -200,7 +196,7 @@ class AtprotoOAuthService {
// For localhost development, use undefined for loopback client
// The BrowserOAuthClient will handle this automatically
if (origin.includes('localhost') || origin.includes('127.0.0.1')) {
console.log('Using loopback client for localhost development');
return undefined as any; // Loopback client
}
@@ -209,7 +205,7 @@ class AtprotoOAuthService {
}
private detectPDSFromHandle(handle: string): string {
console.log('Detecting PDS for handle:', handle);
// Supported PDS hosts and their corresponding handles
const pdsMapping = {
@@ -220,22 +216,22 @@ class AtprotoOAuthService {
// Check if handle ends with known PDS domains
for (const [domain, pdsUrl] of Object.entries(pdsMapping)) {
if (handle.endsWith(`.${domain}`)) {
console.log(`Handle ${handle} mapped to PDS: ${pdsUrl}`);
return pdsUrl;
}
}
// Default to bsky.social
console.log(`Handle ${handle} using default PDS: https://bsky.social`);
return 'https://bsky.social';
}
async initiateOAuthFlow(handle?: string): Promise<void> {
try {
console.log('=== INITIATING OAUTH FLOW ===');
if (!this.oauthClient) {
console.log('OAuth client not initialized, initializing now...');
await this.initialize();
}
@@ -251,15 +247,15 @@ class AtprotoOAuthService {
}
}
console.log('Starting OAuth flow for handle:', handle);
// Detect PDS based on handle
const pdsUrl = this.detectPDSFromHandle(handle);
console.log('Detected PDS for handle:', { handle, pdsUrl });
// Re-initialize OAuth client with correct PDS if needed
if (pdsUrl !== 'https://bsky.social') {
console.log('Re-initializing OAuth client for custom PDS:', pdsUrl);
this.oauthClient = await BrowserOAuthClient.load({
clientId: this.getClientId(),
handleResolver: pdsUrl,
@@ -267,20 +263,14 @@ class AtprotoOAuthService {
}
// Start OAuth authorization flow
console.log('Calling oauthClient.authorize with handle:', handle);
try {
const authUrl = await this.oauthClient.authorize(handle, {
scope: 'atproto transition:generic',
});
console.log('Authorization URL generated:', authUrl.toString());
console.log('URL breakdown:', {
protocol: authUrl.protocol,
hostname: authUrl.hostname,
pathname: authUrl.pathname,
search: authUrl.search
});
// Store some debug info before redirect
sessionStorage.setItem('oauth_debug_pre_redirect', JSON.stringify({
@@ -291,35 +281,30 @@ class AtprotoOAuthService {
}));
// Redirect to authorization server
console.log('About to redirect to:', authUrl.toString());
window.location.href = authUrl.toString();
} catch (authorizeError) {
console.error('oauthClient.authorize failed:', authorizeError);
console.error('Error details:', {
name: authorizeError.name,
message: authorizeError.message,
stack: authorizeError.stack
});
throw authorizeError;
}
} catch (error) {
console.error('Failed to initiate OAuth flow:', error);
throw new Error(`OAuth認証の開始に失敗しました: ${error}`);
}
}
async handleOAuthCallback(): Promise<{ did: string; handle: string } | null> {
try {
console.log('=== HANDLING OAUTH CALLBACK ===');
console.log('Current URL:', window.location.href);
console.log('URL hash:', window.location.hash);
console.log('URL search:', window.location.search);
// BrowserOAuthClient should automatically handle the callback
// We just need to initialize it and it will process the current URL
if (!this.oauthClient) {
console.log('OAuth client not initialized, initializing now...');
await this.initialize();
}
@@ -327,11 +312,11 @@ class AtprotoOAuthService {
throw new Error('Failed to initialize OAuth client');
}
console.log('OAuth client ready, initializing to process callback...');
// Call init() again to process the callback URL
const result = await this.oauthClient.init();
console.log('OAuth callback processing result:', result);
if (result?.session) {
// Process the session
@@ -339,47 +324,42 @@ class AtprotoOAuthService {
}
// If no session yet, wait a bit and try again
console.log('No session found immediately, waiting...');
await new Promise(resolve => setTimeout(resolve, 1000));
// Try to check session again
const sessionCheck = await this.checkSession();
if (sessionCheck) {
console.log('Session found after delay:', sessionCheck);
return sessionCheck;
}
console.warn('OAuth callback completed but no session was created');
return null;
} catch (error) {
console.error('OAuth callback handling failed:', error);
console.error('Error details:', {
name: error.name,
message: error.message,
stack: error.stack
});
throw new Error(`OAuth認証の完了に失敗しました: ${error.message}`);
}
}
async checkSession(): Promise<{ did: string; handle: string } | null> {
try {
console.log('=== CHECK SESSION CALLED ===');
if (!this.oauthClient) {
console.log('No OAuth client, initializing...');
await this.initialize();
}
if (!this.oauthClient) {
console.log('OAuth client initialization failed');
return null;
}
console.log('Running oauthClient.init() to check session...');
const result = await this.oauthClient.init();
console.log('oauthClient.init() result:', result);
if (result?.session) {
// Use the common session processing method
@@ -388,7 +368,7 @@ class AtprotoOAuthService {
return null;
} catch (error) {
console.error('Session check failed:', error);
return null;
}
}
@@ -398,13 +378,7 @@ class AtprotoOAuthService {
}
getSession(): AtprotoSession | null {
console.log('getSession called');
console.log('Current state:', {
hasAgent: !!this.agent,
hasAgentSession: !!this.agent?.session,
hasOAuthClient: !!this.oauthClient,
hasSessionInfo: !!(this as any)._sessionInfo
});
// First check if we have an agent with session
if (this.agent?.session) {
@@ -414,7 +388,7 @@ class AtprotoOAuthService {
accessJwt: this.agent.session.accessJwt || '',
refreshJwt: this.agent.session.refreshJwt || '',
};
console.log('Returning agent session:', session);
return session;
}
@@ -426,11 +400,11 @@ class AtprotoOAuthService {
accessJwt: 'dpop-protected', // Indicate that tokens are handled by dpopFetch
refreshJwt: 'dpop-protected',
};
console.log('Returning stored session info:', session);
return session;
}
console.log('No session available');
return null;
}
@@ -450,28 +424,28 @@ class AtprotoOAuthService {
async logout(): Promise<void> {
try {
console.log('=== LOGGING OUT ===');
// Clear Agent
this.agent = null;
console.log('Agent cleared');
// Clear BrowserOAuthClient session
if (this.oauthClient) {
console.log('Clearing OAuth client session...');
try {
// BrowserOAuthClient may have a revoke or signOut method
if (typeof (this.oauthClient as any).signOut === 'function') {
await (this.oauthClient as any).signOut();
console.log('OAuth client signed out');
} else if (typeof (this.oauthClient as any).revoke === 'function') {
await (this.oauthClient as any).revoke();
console.log('OAuth client revoked');
} else {
console.log('No explicit signOut method found on OAuth client');
}
} catch (oauthError) {
console.error('OAuth client logout error:', oauthError);
}
// Reset the OAuth client to force re-initialization
@@ -492,11 +466,11 @@ class AtprotoOAuthService {
}
}
keysToRemove.forEach(key => {
console.log('Removing localStorage key:', key);
localStorage.removeItem(key);
});
console.log('=== LOGOUT COMPLETED ===');
// Force page reload to ensure clean state
setTimeout(() => {
@@ -504,7 +478,7 @@ class AtprotoOAuthService {
}, 100);
} catch (error) {
console.error('Logout failed:', error);
}
}
@@ -519,8 +493,8 @@ class AtprotoOAuthService {
const did = sessionInfo.did;
try {
console.log('Saving cards to atproto collection...');
console.log('Using DID:', did);
// Ensure we have a fresh agent
if (!this.agent) {
@@ -550,13 +524,6 @@ class AtprotoOAuthService {
createdAt: createdAt
};
console.log('PutRecord request:', {
repo: did,
collection: collection,
rkey: rkey,
record: record
});
// Use Agent's com.atproto.repo.putRecord method
const response = await this.agent.com.atproto.repo.putRecord({
@@ -566,9 +533,9 @@ class AtprotoOAuthService {
record: record
});
console.log('カードデータをai.card.boxに保存しました:', response);
} catch (error) {
console.error('カードボックス保存エラー:', error);
throw error;
}
}
@@ -584,8 +551,8 @@ class AtprotoOAuthService {
const did = sessionInfo.did;
try {
console.log('Fetching cards from atproto collection...');
console.log('Using DID:', did);
// Ensure we have a fresh agent
if (!this.agent) {
@@ -598,7 +565,7 @@ class AtprotoOAuthService {
rkey: 'self'
});
console.log('Cards from box response:', response);
// Convert to expected format
const result = {
@@ -611,7 +578,7 @@ class AtprotoOAuthService {
return result;
} catch (error) {
console.error('カードボックス取得エラー:', error);
// If record doesn't exist, return empty
if (error.toString().includes('RecordNotFound')) {
@@ -633,8 +600,8 @@ class AtprotoOAuthService {
const did = sessionInfo.did;
try {
console.log('Deleting card box collection...');
console.log('Using DID:', did);
// Ensure we have a fresh agent
if (!this.agent) {
@@ -647,33 +614,35 @@ class AtprotoOAuthService {
rkey: 'self'
});
console.log('Card box deleted successfully:', response);
} catch (error) {
console.error('カードボックス削除エラー:', error);
throw error;
}
}
// 手動でトークンを設定(開発・デバッグ用)
setManualTokens(accessJwt: string, refreshJwt: string): void {
console.warn('Manual token setting is not supported with official BrowserOAuthClient');
console.warn('Please use the proper OAuth flow instead');
// For backward compatibility, store in localStorage
const adminDid = import.meta.env.VITE_ADMIN_DID || 'did:plc:unknown';
const appHost = import.meta.env.VITE_APP_HOST || 'https://example.com';
const session: AtprotoSession = {
did: 'did:plc:uqzpqmrjnptsxezjx4xuh2mn',
handle: 'syui.ai',
did: adminDid,
handle: new URL(appHost).hostname,
accessJwt: accessJwt,
refreshJwt: refreshJwt
};
localStorage.setItem('atproto_session', JSON.stringify(session));
console.log('Manual tokens stored in localStorage for backward compatibility');
}
// 後方互換性のための従来関数
saveSessionToStorage(session: AtprotoSession): void {
console.warn('saveSessionToStorage is deprecated with BrowserOAuthClient');
localStorage.setItem('atproto_session', JSON.stringify(session));
}

Some files were not shown because too many files have changed in this diff Show More