5 Commits

Author SHA1 Message Date
721c9b4e71 add github
Some checks failed
Deploy to Cloudflare Pages / deploy (push) Failing after 12m8s
2025-06-14 00:05:31 +09:00
efc73490d1 fix color 2025-06-14 00:05:29 +09:00
1eda00f2d3 fix loading
Some checks failed
Deploy ailog / build-and-deploy (push) Failing after 12m59s
2025-06-13 16:49:42 +09:00
ac4c3f2ad3 fix
Some checks failed
Deploy ailog / build-and-deploy (push) Failing after 11m6s
2025-06-13 15:31:32 +09:00
2e0fe0edfc add ask AI
Some checks failed
Deploy ailog / build-and-deploy (push) Failing after 14m44s
2025-06-13 15:01:46 +09:00
80 changed files with 2303 additions and 4799 deletions

View File

@@ -37,18 +37,7 @@
"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:*)"
"Bash(hugo:*)"
],
"deny": []
}

View File

@@ -1,53 +0,0 @@
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

@@ -1,28 +0,0 @@
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

View File

@@ -34,67 +34,22 @@ jobs:
- 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
mkdir -p my-blog/static/assets
cp -r oauth/dist/assets/* my-blog/static/assets/
cp oauth/dist/index.html my-blog/static/oauth/index.html || true
- name: Cache ailog binary
uses: actions/cache@v4
- name: Setup Rust
uses: actions-rs/toolchain@v1
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
toolchain: stable
- name: Build ailog
run: cargo build --release
- name: Build site with ailog
run: |
cd my-blog
../bin/ailog build
../target/release/ailog build
- name: List public directory
run: |

View File

@@ -1,92 +0,0 @@
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

View File

@@ -1,169 +0,0 @@
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 }}

3
.gitignore vendored
View File

@@ -10,6 +10,3 @@ my-blog/public/
dist
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.6"
version = "0.1.0"
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 = ["rt-multi-thread", "macros", "fs", "net", "io-util", "sync", "time", "process", "signal"] }
tokio = { version = "1.40", features = ["full"] }
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", "rustls-tls"], default-features = false }
reqwest = { version = "0.12", features = ["json"] }
rand = "0.8"
sha2 = "0.10"
base64 = "0.22"
@@ -43,38 +43,12 @@ cookie = "0.18"
syn = { version = "2.0", features = ["full", "parsing", "visit"] }
quote = "1.0"
ignore = "0.4"
git2 = { version = "0.18", features = ["vendored-openssl", "vendored-libgit2", "ssh"], default-features = false }
git2 = "0.18"
regex = "1.0"
# ATProto and stream monitoring dependencies
tokio-tungstenite = { version = "0.21", features = ["rustls-tls-webpki-roots", "connect"], default-features = false }
tokio-tungstenite = { version = "0.21", features = ["native-tls"] }
futures-util = "0.3"
tungstenite = { version = "0.21", features = ["rustls-tls-webpki-roots"], default-features = false }
tungstenite = { version = "0.21", features = ["native-tls"] }
[dev-dependencies]
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
tempfile = "3.14"

908
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -1,128 +0,0 @@
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

View File

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

View File

@@ -1,173 +0,0 @@
#!/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.

View File

@@ -1,30 +0,0 @@
#!/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,19 +1,5 @@
# エコシステム統合設計書
## 注意事項
`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の探求
- **唯一性原則**: 現実の個人の唯一性をすべてのシステムで担保

View File

@@ -7,7 +7,6 @@ author = "syui"
[build]
highlight_code = true
highlight_theme = "Monokai"
minify = false
[ai]
@@ -16,14 +15,15 @@ auto_translate = false
comment_moderation = false
ask_ai = true
provider = "ollama"
model = "gemma3:4b"
model = "gemma3:2b"
host = "https://ollama.syui.ai"
system_prompt = "あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"
system_prompt = "you are a helpful ai assistant trained on this blog's content. you can answer questions about the articles, provide insights, and help users understand the topics discussed."
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"
collection_comment = "ai.syui.log"
collection_user = "ai.syui.log.user"
collection_chat = "ai.syui.log.chat"

View File

@@ -1,14 +1,18 @@
---
title: "静的サイトジェネレータを作った"
slug: "ailog"
slug: "ailog-system-introduction"
date: "2025-06-12"
tags: ["blog", "rust", "mcp", "atp"]
language: ["ja", "en"]
---
rustで静的サイトジェネレータを作ました。[ailog](https://git.syui.ai/ai/log)といいます。`hugo`からの移行になります。
rustで静的サイトジェネレータを作ることにしました。[ailog](https://git.syui.ai/ai/log)といいます。`hugo`からの移行になります。
`ailog`は、最初にatproto-comment-system(oauth)とask-AIという機能をつけました
ブログを書く環境もこれから変わってくると思っていて、例えば、`docs`, `readme`, `blog`などはAIが生成、または支援することになるだろうと予測しています。langの自動生成もAIが担当することになるでしょう
これは、音声に限らず、プログラミング言語から、osなど、様々なtranslateがAIの自動生成になるかもしれません。
`ailog`は、最初にatproto-comment-system(oauth)とask-AIというAI機能をつけました。
## quick start
@@ -17,7 +21,7 @@ $ git clone https://git.syui.ai/ai/log
$ cd log
$ cargo build
$ ./target/debug/ailog init my-blog
$ ./target/debug/ailog serve my-blog
$ ./target/debug/ailog server my-blog
```
## install
@@ -30,7 +34,7 @@ $ export RUSTUP_HOME="$HOME/.rustup"
$ export PATH="$HOME/.cargo/bin:$PATH"
---
$ which ailog
$ ailog -h
$ ailog
```
## build deploy
@@ -77,13 +81,7 @@ AILOG_COLLECTION_USER=ai.syui.log.user
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`です。
@@ -137,23 +135,3 @@ $ ailog stream server
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

@@ -1,66 +0,0 @@
---
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,34 @@
---
title: "Welcome to ailog"
slug: welcome-to-ailog
date: 2025-01-06
tags: ["welcome", "ailog"]
language: en
---
# Welcome to ailog
This is your first post powered by **ailog** - a static blog generator with AI features.
## Features
- Fast static site generation
- Markdown support with frontmatter
- AI-powered features (coming soon)
- atproto integration for comments
## Getting Started
Create new posts with:
```bash
ailog new "My New Post"
```
Build your blog with:
```bash
ailog build
```
Happy blogging!

View File

@@ -1,7 +0,0 @@
{{ $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 -}}

View File

@@ -16,32 +16,11 @@
Cache-Control: public, max-age=31536000, immutable
/css/*
Content-Type: text/css
Cache-Control: no-cache
Cache-Control: public, max-age=31536000, immutable
/*.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

View File

@@ -1,3 +1,9 @@
# AI機能をai.gpt MCP serverにリダイレクト
/api/ask https://ai-gpt-mcp.syui.ai/ask 200
# Ollama API proxy (Cloudflare Workers)
/api/ollama-proxy https://ollama-proxy.YOUR-SUBDOMAIN.workers.dev/:splat 200
# OAuth routes
/oauth/* /oauth/index.html 200

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,13 +1,13 @@
{
"client_id": "https://syui.ai/client-metadata.json",
"client_id": "https://log.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",
"client_uri": "https://log.syui.ai",
"logo_uri": "https://log.syui.ai/favicon.ico",
"tos_uri": "https://log.syui.ai/terms",
"policy_uri": "https://log.syui.ai/privacy",
"redirect_uris": [
"https://syui.ai/oauth/callback",
"https://syui.ai/"
"https://log.syui.ai/oauth/callback",
"https://log.syui.ai/"
],
"response_types": [
"code"

File diff suppressed because it is too large Load Diff

View File

@@ -1,342 +0,0 @@
/* 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); }
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -1,22 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 4.8 KiB

View File

@@ -1,3 +1,3 @@
<!-- OAuth Comment System - Load globally for session management -->
<script type="module" crossorigin src="/assets/comment-atproto-C3utAhPv.js"></script>
<link rel="stylesheet" crossorigin href="/assets/comment-atproto-BH-72ESb.css">
<script type="module" crossorigin src="/assets/comment-atproto-CDastf61.js"></script>
<link rel="stylesheet" crossorigin href="/assets/comment-atproto-B330B6QX.css">

View File

@@ -1,31 +0,0 @@
[
{
"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"
}
]

View File

@@ -1,281 +0,0 @@
/**
* 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
if (e.key === 'Enter' && e.target.id === 'aiQuestion' && !e.shiftKey) {
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

@@ -1,94 +0,0 @@
/**
* 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

@@ -1,165 +0,0 @@
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

@@ -1,6 +0,0 @@
/*!
* 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

@@ -1,6 +0,0 @@
/*!
* 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}

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -1,99 +0,0 @@
@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";
}

View File

@@ -1,24 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 4.8 KiB

View File

@@ -4,17 +4,10 @@
<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">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
{% include "oauth-assets.html" %}
{% block head %}{% endblock %}
</head>
<body>
@@ -22,53 +15,31 @@
<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">
<!-- Ask AI button on all pages -->
<button class="ask-ai-btn" onclick="toggleAskAI()" id="askAiButton">
<span class="ai-icon icon-ai"></span>
ai
<span class="ai-icon">🤖</span>
Ask AI
</button>
</div>
</div>
</header>
<!-- Ask AI Panel -->
<!-- Ask AI panel on all pages -->
<div class="ask-ai-panel" id="askAiPanel" style="display: none;">
<div class="ask-ai-content">
<!-- Authentication check -->
<div id="authCheck" class="auth-check">
<p>🔒 Please login with ATProto to use Ask AI feature</p>
</div>
<!-- Chat form (hidden until authenticated) -->
<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>
<!-- Chat history -->
<div id="chatHistory" class="chat-history" style="display: none;"></div>
</div>
</div>
@@ -81,17 +52,309 @@
</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>
<p>&copy; {{ config.author }}</p>
</footer>
<script src="/js/ask-ai.js"></script>
<script src="/js/theme.js"></script>
{% include "oauth-assets.html" %}
<script>
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 after a small delay to ensure element is visible
setTimeout(() => {
document.getElementById('aiQuestion').focus();
}, 50);
} else {
// User is not authenticated - show login message only
document.getElementById('authCheck').style.display = 'block';
document.getElementById('chatForm').style.display = 'none';
document.getElementById('chatHistory').style.display = 'none';
}
}
let isAIChatReady = false;
let aiProfileData = null;
// Listen for AI ready signal
window.addEventListener('aiChatReady', function() {
isAIChatReady = true;
console.log('AI Chat is ready');
});
// Listen for AI profile updates from OAuth app
window.addEventListener('aiProfileLoaded', function(event) {
aiProfileData = event.detail;
console.log('AI profile loaded:', aiProfileData);
updateAskAIButton();
});
function updateAskAIButton() {
const button = document.getElementById('askAiButton');
const iconSpan = button.querySelector('.ai-icon');
if (aiProfileData && aiProfileData.avatar) {
iconSpan.innerHTML = `<img src="${aiProfileData.avatar}" alt="${aiProfileData.displayName || 'AI'}" class="ai-avatar-small">`;
}
if (aiProfileData && aiProfileData.displayName) {
button.childNodes[2].textContent = `Ask ${aiProfileData.displayName}`;
}
}
function showInitialGreeting() {
const chatHistory = document.getElementById('chatHistory');
const greetingDiv = document.createElement('div');
greetingDiv.className = 'chat-message ai-message comment-style initial-greeting';
if (!aiProfileData) {
return; // Don't show greeting if no AI profile data
}
let avatarElement = '🤖';
if (aiProfileData.avatar) {
avatarElement = `<img src="${aiProfileData.avatar}" alt="${aiProfileData.displayName}" class="profile-avatar">`;
}
const displayName = aiProfileData.displayName;
const handle = aiProfileData.handle;
greetingDiv.innerHTML = `
<div class="message-header">
<div class="avatar">${avatarElement}</div>
<div class="user-info">
<div class="display-name">${displayName}</div>
<div class="handle">@${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);
}
async function askQuestion() {
const question = document.getElementById('aiQuestion').value;
const chatHistory = document.getElementById('chatHistory');
const askButton = document.getElementById('askButton');
if (!question.trim()) return;
// Wait for AI to be ready
if (!isAIChatReady) {
console.log('Waiting for AI Chat to be ready...');
await new Promise(resolve => {
const checkReady = setInterval(() => {
if (isAIChatReady) {
clearInterval(checkReady);
resolve();
}
}, 100);
});
}
// Disable button and show loading
askButton.disabled = true;
askButton.textContent = 'Posting...';
// Get user info from OAuth component
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('@', '');
}
}
// Add question to chat history in comment style
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);
// Clear input
document.getElementById('aiQuestion').value = '';
try {
// Show loading immediately
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);
// Post question to ATProto via OAuth app
const event = new CustomEvent('postAIQuestion', {
detail: { question: question }
});
window.dispatchEvent(event);
} catch (error) {
// Remove loading indicator and show error
const loadingMsg = chatHistory.querySelector('.ai-loading-simple');
if (loadingMsg) {
loadingMsg.remove();
}
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">Sorry, I encountered an error. Please try again.</div>
`;
chatHistory.appendChild(errorDiv);
} finally {
askButton.disabled = false;
askButton.textContent = 'Ask';
}
}
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
document.getElementById('askAiPanel').style.display = 'none';
}
// Enter key to send message
if (e.key === 'Enter' && e.target.id === 'aiQuestion' && !e.shiftKey) {
e.preventDefault();
askQuestion();
}
});
// Monitor authentication state changes
const authObserver = new MutationObserver(function(mutations) {
const userSections = document.querySelectorAll('.user-section');
if (userSections.length > 0) {
checkAuthenticationStatus();
// Stop observing once authenticated
authObserver.disconnect();
}
});
// Start observing for authentication changes
document.addEventListener('DOMContentLoaded', function() {
// Initial authentication check with slight delay for OAuth component
setTimeout(() => {
checkAuthenticationStatus();
}, 500);
authObserver.observe(document.body, {
childList: true,
subtree: true
});
});
// Listen for AI responses from OAuth app
window.addEventListener('aiResponseReceived', function(event) {
const chatHistory = document.getElementById('chatHistory');
const loadingMsg = chatHistory.querySelector('.ai-loading-simple');
if (loadingMsg) {
loadingMsg.remove();
}
const aiProfile = event.detail.aiProfile;
if (!aiProfile || !aiProfile.handle || !aiProfile.displayName) {
console.error('AI profile data is missing, cannot display response');
return;
}
const timestamp = new Date(event.detail.timestamp || Date.now());
// Create comment-style AI response
const answerDiv = document.createElement('div');
answerDiv.className = 'chat-message ai-message comment-style';
// Prepare avatar
let avatarElement = '🤖';
if (aiProfile.avatar) {
avatarElement = `<img src="${aiProfile.avatar}" alt="${aiProfile.displayName}" class="profile-avatar">`;
}
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">${event.detail.answer}</div>
`;
chatHistory.appendChild(answerDiv);
// Auto-expand content instead of scrolling
if (chatHistory.children.length > 5) {
const oldestMessage = chatHistory.children[0];
if (oldestMessage && oldestMessage.classList.contains('user-message')) {
// Keep the latest 5 exchanges (10 messages)
if (chatHistory.children.length > 10) {
chatHistory.removeChild(oldestMessage);
if (chatHistory.children.length > 0) {
chatHistory.removeChild(chatHistory.children[0]);
}
}
}
}
});
</script>
</body>
</html>
</html>

View File

@@ -27,7 +27,7 @@
<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>
<a href="{{ post.markdown_url }}" class="view-markdown" title="View Markdown">Markdown</a>
{% endif %}
{% if post.translation_url %}
<a href="{{ post.translation_url }}" class="view-translation" title="View Translation">🌐</a>

View File

@@ -1,3 +1,3 @@
<!-- OAuth Comment System - Load globally for session management -->
<script type="module" crossorigin src="/assets/comment-atproto-C3utAhPv.js"></script>
<link rel="stylesheet" crossorigin href="/assets/comment-atproto-BH-72ESb.css">
<script type="module" crossorigin src="/assets/comment-atproto-CDastf61.js"></script>
<link rel="stylesheet" crossorigin href="/assets/comment-atproto-B330B6QX.css">

View File

@@ -16,7 +16,7 @@
<div class="article-actions">
{% if post.markdown_url %}
<a href="{{ post.markdown_url }}" class="action-btn markdown-btn" title="View Markdown">
.md
📝 Markdown
</a>
{% endif %}
{% if post.translation_url %}

View File

@@ -16,7 +16,7 @@
<div class="article-actions">
{% if post.markdown_url %}
<a href="{{ post.markdown_url }}" class="action-btn markdown-btn" title="View Markdown">
.md
📝 Markdown
</a>
{% endif %}
{% if post.translation_url %}

View File

@@ -16,7 +16,7 @@
<div class="article-actions">
{% if post.markdown_url %}
<a href="{{ post.markdown_url }}" class="action-btn markdown-btn" title="View Markdown">
.md
Markdown
</a>
{% endif %}
{% if post.translation_url %}

View File

@@ -1,11 +1,18 @@
# 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_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
VITE_ADMIN_DID=did:plc:uqzpqmrjnptsxezjx4xuh2mn
# Base collection (all others are derived via getCollectionNames)
VITE_OAUTH_COLLECTION=ai.syui.log
# Collection names for OAuth app
VITE_COLLECTION_COMMENT=ai.syui.log
VITE_COLLECTION_USER=ai.syui.log.user
VITE_COLLECTION_CHAT=ai.syui.log.chat
# Collection names for ailog (backward compatibility)
AILOG_COLLECTION_COMMENT=ai.syui.log
AILOG_COLLECTION_USER=ai.syui.log.user
AILOG_COLLECTION_CHAT=ai.syui.log.chat
# AI Configuration
VITE_AI_ENABLED=true
@@ -18,4 +25,4 @@ 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

@@ -162,68 +162,11 @@
}
.app-main {
max-width: 1000px;
max-width: 1200px;
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;
@@ -321,10 +264,9 @@
.comment-section {
max-width: 800px;
margin: 0 auto;
/* padding: 20px; - removed to avoid double padding */
padding: 20px;
}
.auth-section {
background: #f8f9fa;
border: 1px solid #e9ecef;
@@ -612,8 +554,6 @@
line-height: 1.5;
color: #333;
margin-bottom: 10px;
white-space: pre-wrap;
word-wrap: break-word;
}
.comment-meta {

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { User } from '../services/auth';
import { atprotoOAuthService } from '../services/atproto-oauth';
import { appConfig, getCollectionNames } from '../config/app';
import { appConfig } from '../config/app';
interface AIChatProps {
user: User | null;
@@ -14,22 +14,26 @@ export const AIChat: React.FC<AIChatProps> = ({ user, isEnabled }) => {
const [isProcessing, setIsProcessing] = useState(false);
const [aiProfile, setAiProfile] = useState<any>(null);
// Get AI settings from appConfig (unified configuration)
// Get AI settings from environment variables
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,
enabled: import.meta.env.VITE_AI_ENABLED === 'true',
askAi: import.meta.env.VITE_AI_ASK_AI === 'true',
provider: import.meta.env.VITE_AI_PROVIDER || 'ollama',
model: import.meta.env.VITE_AI_MODEL || 'gemma3:4b',
host: import.meta.env.VITE_AI_HOST || 'https://ollama.syui.ai',
systemPrompt: import.meta.env.VITE_AI_SYSTEM_PROMPT || 'You are a helpful AI assistant trained on this blog\'s content.',
aiDid: import.meta.env.VITE_AI_DID || 'did:plc:uqzpqmrjnptsxezjx4xuh2mn',
bskyPublicApi: import.meta.env.VITE_BSKY_PUBLIC_API || 'https://public.api.bsky.app',
};
// Fetch AI profile on load
useEffect(() => {
const fetchAIProfile = async () => {
console.log('=== AI PROFILE FETCH START ===');
console.log('AI DID:', aiConfig.aiDid);
if (!aiConfig.aiDid) {
console.log('No AI DID configured');
return;
}
@@ -37,7 +41,9 @@ export const AIChat: React.FC<AIChatProps> = ({ user, isEnabled }) => {
// Try with agent first
const agent = atprotoOAuthService.getAgent();
if (agent) {
console.log('Fetching AI profile with agent for DID:', aiConfig.aiDid);
const profile = await agent.getProfile({ actor: aiConfig.aiDid });
console.log('AI profile fetched successfully:', profile.data);
const profileData = {
did: aiConfig.aiDid,
handle: profile.data.handle,
@@ -45,17 +51,21 @@ export const AIChat: React.FC<AIChatProps> = ({ user, isEnabled }) => {
avatar: profile.data.avatar,
description: profile.data.description
};
console.log('Setting aiProfile to:', profileData);
setAiProfile(profileData);
// Dispatch event to update Ask AI button
window.dispatchEvent(new CustomEvent('aiProfileLoaded', { detail: profileData }));
console.log('=== AI PROFILE FETCH SUCCESS (AGENT) ===');
return;
}
// Fallback to public API
console.log('No agent available, trying public API for AI profile');
const response = await fetch(`${aiConfig.bskyPublicApi}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(aiConfig.aiDid)}`);
if (response.ok) {
const profileData = await response.json();
console.log('AI profile fetched via public API:', profileData);
const profile = {
did: aiConfig.aiDid,
handle: profileData.handle,
@@ -63,15 +73,21 @@ export const AIChat: React.FC<AIChatProps> = ({ user, isEnabled }) => {
avatar: profileData.avatar,
description: profileData.description
};
console.log('Setting aiProfile to:', profile);
setAiProfile(profile);
// Dispatch event to update Ask AI button
window.dispatchEvent(new CustomEvent('aiProfileLoaded', { detail: profile }));
console.log('=== AI PROFILE FETCH SUCCESS (PUBLIC API) ===');
return;
} else {
console.error('Public API failed with status:', response.status);
}
} catch (error) {
console.error('Failed to fetch AI profile:', error);
setAiProfile(null);
}
console.log('=== AI PROFILE FETCH FAILED ===');
};
fetchAIProfile();
@@ -84,6 +100,9 @@ export const AIChat: React.FC<AIChatProps> = ({ user, isEnabled }) => {
const handleAIQuestion = async (event: any) => {
if (!user || !event.detail || !event.detail.question || isProcessing || !aiProfile) return;
console.log('AIChat received question:', event.detail.question);
console.log('Current aiProfile state:', aiProfile);
setIsProcessing(true);
try {
await postQuestionAndGenerateResponse(event.detail.question);
@@ -95,6 +114,7 @@ export const AIChat: React.FC<AIChatProps> = ({ user, isEnabled }) => {
// Add listener with a small delay to ensure it's ready
setTimeout(() => {
window.addEventListener('postAIQuestion', handleAIQuestion);
console.log('AIChat event listener registered');
// Notify that AI is ready
window.dispatchEvent(new CustomEvent('aiChatReady'));
@@ -114,50 +134,40 @@ export const AIChat: React.FC<AIChatProps> = ({ user, isEnabled }) => {
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,
$type: appConfig.collections.chat,
question: question,
url: window.location.href,
createdAt: now.toISOString(),
author: {
did: user.did,
handle: user.handle,
avatar: user.avatar,
displayName: user.displayName || user.handle,
},
createdAt: now.toISOString(),
context: {
page_title: document.title,
page_url: window.location.href,
},
};
await agent.api.com.atproto.repo.putRecord({
repo: user.did,
collection: collections.chat,
collection: appConfig.collections.chat,
rkey: rkey,
record: questionRecord,
});
console.log('Question posted to ATProto');
// 2. Get chat history
const chatRecords = await agent.api.com.atproto.repo.listRecords({
repo: user.did,
collection: collections.chat,
collection: appConfig.collections.chat,
limit: 10,
});
@@ -165,10 +175,10 @@ export const AIChat: React.FC<AIChatProps> = ({ user, isEnabled }) => {
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}`;
if (r.value.question) {
return `User: ${r.value.question}`;
} else if (r.value.answer) {
return `AI: ${r.value.answer}`;
}
return '';
})
@@ -225,38 +235,37 @@ Answer:`;
// 5. Save AI response in background
const answerRkey = now.toISOString().replace(/[:.]/g, '-') + '-answer';
console.log('=== SAVING AI ANSWER ===');
console.log('Current aiProfile:', aiProfile);
const answerRecord = {
$type: collections.chat,
post: {
url: currentUrl,
slug: postSlug,
title: postTitle,
date: new Date().toISOString(),
tags: [],
language: "ja"
},
type: "answer",
text: aiAnswer,
$type: appConfig.collections.chat,
answer: aiAnswer,
question_rkey: rkey,
url: window.location.href,
createdAt: now.toISOString(),
author: {
did: aiProfile.did,
handle: aiProfile.handle,
displayName: aiProfile.displayName,
avatar: aiProfile.avatar,
},
createdAt: now.toISOString(),
};
console.log('Answer record to save:', answerRecord);
// Save to ATProto asynchronously (don't wait for it)
agent.api.com.atproto.repo.putRecord({
repo: user.did,
collection: collections.chat,
collection: appConfig.collections.chat,
rkey: answerRkey,
record: answerRecord,
}).catch(err => {
// Silent fail for AI response saving
console.error('Failed to save AI response to ATProto:', err);
});
} catch (error) {
console.error('Failed to generate AI response:', error);
window.dispatchEvent(new CustomEvent('aiResponseError', {
detail: { error: 'AI応答の生成に失敗しました' }
}));

View File

@@ -1,9 +1,10 @@
// Application configuration
export interface AppConfig {
adminDid: string;
aiDid: string;
collections: {
base: string; // Base collection like "ai.syui.log"
comment: string;
user: string;
chat: string;
};
host: string;
rkey?: string; // Current post rkey if on post page
@@ -12,33 +13,13 @@ export interface AppConfig {
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 {
function generateCollectionNames(host: string): { comment: string; user: string; chat: string } {
try {
// Remove protocol if present
const cleanHost = host.replace(/^https?:\/\//, '');
@@ -53,19 +34,29 @@ function generateBaseCollectionFromHost(host: string): string {
// Reverse the parts for collection naming
// log.syui.ai -> ai.syui.log
const reversedParts = parts.reverse();
const result = reversedParts.join('.');
return result;
const collectionBase = reversedParts.join('.');
return {
comment: collectionBase,
user: `${collectionBase}.user`,
chat: `${collectionBase}.chat`
};
} catch (error) {
// Fallback to default
return 'ai.syui.log';
console.warn('Failed to generate collection names from host:', host, error);
// Fallback to default collections
return {
comment: 'ai.syui.log',
user: 'ai.syui.log.user',
chat: 'ai.syui.log.chat'
};
}
}
// Extract rkey from current URL
// /posts/xxx -> xxx
// /posts/xxx.html -> xxx
function extractRkeyFromUrl(): string | undefined {
const pathname = window.location.pathname;
const match = pathname.match(/\/posts\/([^/]+)\/?$/);
const match = pathname.match(/\/posts\/([^/]+)\.html$/);
return match ? match[1] : undefined;
}
@@ -73,19 +64,13 @@ function extractRkeyFromUrl(): string | undefined {
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 autoGeneratedCollections = generateCollectionNames(host);
const collections = {
base: baseCollection,
comment: import.meta.env.VITE_COLLECTION_COMMENT || autoGeneratedCollections.comment,
user: import.meta.env.VITE_COLLECTION_USER || autoGeneratedCollections.user,
chat: import.meta.env.VITE_COLLECTION_CHAT || autoGeneratedCollections.chat,
};
const rkey = extractRkeyFromUrl();
@@ -96,14 +81,19 @@ export function getAppConfig(): AppConfig {
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';
console.log('App configuration:', {
host,
adminDid,
collections,
rkey: rkey || 'none (not on post page)',
ai: { enabled: aiEnabled, askAi: aiAskAi, provider: aiProvider, model: aiModel, host: aiHost },
bskyPublicApi
});
return {
adminDid,
aiDid,
collections,
host,
rkey,
@@ -112,9 +102,7 @@ export function getAppConfig(): AppConfig {
aiProvider,
aiModel,
aiHost,
aiSystemPrompt,
bskyPublicApi,
atprotoApi
bskyPublicApi
};
}

View File

@@ -73,6 +73,7 @@ export const aiCardApi = {
});
return response.data.data;
} catch (error) {
console.warn('ai.gpt AI分析機能が利用できません:', error);
throw new Error('AI分析機能を利用するにはai.gptサーバーが必要です');
}
},
@@ -85,6 +86,7 @@ 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,33 +43,39 @@ 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
@@ -77,36 +83,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;
@@ -115,18 +121,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',
@@ -139,7 +145,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 {
@@ -148,11 +154,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
@@ -163,20 +169,18 @@ 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: 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;
// Method 3: Hardcoded fallback for known DIDs
if (did === 'did:plc:uqzpqmrjnptsxezjx4xuh2mn') {
handle = 'syui.ai';
(this as any)._sessionInfo.handle = handle;
console.log('Using hardcoded handle for known DID');
}
}
@@ -187,7 +191,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;
}
@@ -196,7 +200,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
}
@@ -205,7 +209,7 @@ class AtprotoOAuthService {
}
private detectPDSFromHandle(handle: string): string {
console.log('Detecting PDS for handle:', handle);
// Supported PDS hosts and their corresponding handles
const pdsMapping = {
@@ -216,22 +220,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();
}
@@ -247,15 +251,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,
@@ -263,14 +267,20 @@ 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({
@@ -281,30 +291,35 @@ 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();
}
@@ -312,11 +327,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
@@ -324,42 +339,47 @@ 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
@@ -368,7 +388,7 @@ class AtprotoOAuthService {
return null;
} catch (error) {
console.error('Session check failed:', error);
return null;
}
}
@@ -378,7 +398,13 @@ 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) {
@@ -388,7 +414,7 @@ class AtprotoOAuthService {
accessJwt: this.agent.session.accessJwt || '',
refreshJwt: this.agent.session.refreshJwt || '',
};
console.log('Returning agent session:', session);
return session;
}
@@ -400,11 +426,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;
}
@@ -424,28 +450,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
@@ -466,11 +492,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(() => {
@@ -478,7 +504,7 @@ class AtprotoOAuthService {
}, 100);
} catch (error) {
console.error('Logout failed:', error);
}
}
@@ -493,8 +519,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) {
@@ -524,6 +550,13 @@ 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({
@@ -533,9 +566,9 @@ class AtprotoOAuthService {
record: record
});
console.log('カードデータをai.card.boxに保存しました:', response);
} catch (error) {
console.error('カードボックス保存エラー:', error);
throw error;
}
}
@@ -551,8 +584,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) {
@@ -565,7 +598,7 @@ class AtprotoOAuthService {
rkey: 'self'
});
console.log('Cards from box response:', response);
// Convert to expected format
const result = {
@@ -578,7 +611,7 @@ class AtprotoOAuthService {
return result;
} catch (error) {
console.error('カードボックス取得エラー:', error);
// If record doesn't exist, return empty
if (error.toString().includes('RecordNotFound')) {
@@ -600,8 +633,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) {
@@ -614,35 +647,33 @@ 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: adminDid,
handle: new URL(appHost).hostname,
did: 'did:plc:uqzpqmrjnptsxezjx4xuh2mn',
handle: 'syui.ai',
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));
}

View File

@@ -53,6 +53,7 @@ export class OAuthEndpointHandler {
}
});
} catch (error) {
console.error('Failed to generate JWKS:', error);
return new Response(JSON.stringify({ error: 'Failed to generate JWKS' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
@@ -61,6 +62,7 @@ export class OAuthEndpointHandler {
}
} catch (e) {
// If URL parsing fails, pass through to original fetch
console.debug('URL parsing failed, passing through:', e);
}
// Pass through all other requests
@@ -134,5 +136,6 @@ export function registerOAuthServiceWorker() {
const blob = new Blob([swCode], { type: 'application/javascript' });
const swUrl = URL.createObjectURL(blob);
navigator.serviceWorker.register(swUrl).catch(console.error);
}
}

View File

@@ -37,6 +37,7 @@ export class OAuthKeyManager {
this.keyPair = await this.importKeyPair(keyData);
return this.keyPair;
} catch (error) {
console.warn('Failed to load stored key, generating new one:', error);
localStorage.removeItem('oauth_private_key');
}
}
@@ -114,6 +115,7 @@ export class OAuthKeyManager {
const privateKey = await window.crypto.subtle.exportKey('jwk', keyPair.privateKey);
localStorage.setItem('oauth_private_key', JSON.stringify(privateKey));
} catch (error) {
console.error('Failed to store private key:', error);
}
}

View File

@@ -28,31 +28,8 @@ pub struct JetstreamConfig {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CollectionConfig {
pub base: String, // Base collection name like "ai.syui.log"
}
impl CollectionConfig {
// Collection name builders
pub fn comment(&self) -> String {
self.base.clone()
}
pub fn user(&self) -> String {
format!("{}.user", self.base)
}
#[allow(dead_code)]
pub fn chat(&self) -> String {
format!("{}.chat", self.base)
}
pub fn chat_lang(&self) -> String {
format!("{}.chat.lang", self.base)
}
pub fn chat_comment(&self) -> String {
format!("{}.chat.comment", self.base)
}
pub comment: String,
pub user: String,
}
impl Default for AuthConfig {
@@ -70,7 +47,8 @@ impl Default for AuthConfig {
collections: vec!["ai.syui.log".to_string()],
},
collections: CollectionConfig {
base: "ai.syui.log".to_string(),
comment: "ai.syui.log".to_string(),
user: "ai.syui.log.user".to_string(),
},
}
}
@@ -242,50 +220,11 @@ pub fn load_config() -> Result<AuthConfig> {
}
let config_json = fs::read_to_string(&config_path)?;
let mut config: AuthConfig = serde_json::from_str(&config_json)?;
// Try to load as new format first, then migrate if needed
match serde_json::from_str::<AuthConfig>(&config_json) {
Ok(mut config) => {
// Update collection configuration
update_config_collections(&mut config);
Ok(config)
}
Err(e) => {
println!("{}", format!("Parse error: {}, attempting migration...", e).yellow());
// Try to migrate from old format
migrate_config_if_needed(&config_path, &config_json)
}
}
}
fn migrate_config_if_needed(config_path: &std::path::Path, config_json: &str) -> Result<AuthConfig> {
// Try to parse as old format and migrate to new simple format
let mut old_config: serde_json::Value = serde_json::from_str(config_json)?;
// Migrate old collections structure to new base-only structure
if let Some(collections) = old_config.get_mut("collections") {
// Extract base collection name from comment field or use default
let base_collection = collections.get("comment")
.and_then(|v| v.as_str())
.unwrap_or("ai.syui.log")
.to_string();
// Replace entire collections structure with new format
old_config["collections"] = serde_json::json!({
"base": base_collection
});
}
// Save migrated config
let migrated_config_json = serde_json::to_string_pretty(&old_config)?;
fs::write(config_path, migrated_config_json)?;
// Parse as new format
let mut config: AuthConfig = serde_json::from_value(old_config)?;
// Update collection configuration
update_config_collections(&mut config);
println!("{}", "✅ Configuration migrated to new simplified format".green());
Ok(config)
}
@@ -320,7 +259,7 @@ async fn test_api_access_with_auth(config: &AuthConfig) -> Result<()> {
let url = format!("{}/xrpc/com.atproto.repo.listRecords?repo={}&collection={}&limit=1",
config.admin.pds,
urlencoding::encode(&config.admin.did),
urlencoding::encode(&config.collections.comment()));
urlencoding::encode(&config.collections.comment));
let response = client
.get(&url)
@@ -372,14 +311,23 @@ fn save_config(config: &AuthConfig) -> Result<()> {
Ok(())
}
// Generate collection config from environment
// Generate collection names from admin DID or environment
fn generate_collection_config() -> CollectionConfig {
// Use VITE_OAUTH_COLLECTION for unified configuration
let base = std::env::var("VITE_OAUTH_COLLECTION")
.unwrap_or_else(|_| "ai.syui.log".to_string());
// Check environment variables first
if let (Ok(comment), Ok(user)) = (
std::env::var("AILOG_COLLECTION_COMMENT"),
std::env::var("AILOG_COLLECTION_USER")
) {
return CollectionConfig {
comment,
user,
};
}
// Default collections
CollectionConfig {
base,
comment: "ai.syui.log".to_string(),
user: "ai.syui.log.user".to_string(),
}
}
@@ -387,5 +335,5 @@ fn generate_collection_config() -> CollectionConfig {
pub fn update_config_collections(config: &mut AuthConfig) {
config.collections = generate_collection_config();
// Also update jetstream collections to monitor the comment collection
config.jetstream.collections = vec![config.collections.comment()];
config.jetstream.collections = vec![config.collections.comment.clone()];
}

View File

@@ -4,32 +4,20 @@ use colored::Colorize;
use std::fs;
use std::path::PathBuf;
pub async fn execute(title: String, slug: Option<String>, format: String) -> Result<()> {
pub async fn execute(title: String, format: String) -> Result<()> {
println!("{} {}", "Creating new post:".green(), title);
let date = Local::now();
// Use provided slug or generate from title
let slug_part = slug.unwrap_or_else(|| {
title
.to_lowercase()
.replace(' ', "-")
.chars()
.filter(|c| c.is_alphanumeric() || *c == '-')
.collect()
});
let filename = format!(
"{}-{}.{}",
date.format("%Y-%m-%d"),
slug_part,
title.to_lowercase().replace(' ', "-"),
format
);
let content = format!(
r#"---
title: "{}"
slug: "{}"
date: {}
tags: []
draft: false
@@ -40,7 +28,6 @@ draft: false
Write your content here...
"#,
title,
slug_part,
date.format("%Y-%m-%d"),
title
);

View File

@@ -45,50 +45,46 @@ pub async fn build(project_dir: PathBuf) -> Result<()> {
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("No admin DID found in [oauth] section"))?;
let collection_base = oauth_config.get("collection")
let collection_comment = oauth_config.get("collection_comment")
.and_then(|v| v.as_str())
.unwrap_or("ai.syui.log");
// Extract AI configuration from ai config if available
let ai_config = config.get("ai").and_then(|v| v.as_table());
let ai_did = ai_config
.and_then(|ai_table| ai_table.get("ai_did"))
let collection_user = oauth_config.get("collection_user")
.and_then(|v| v.as_str())
.unwrap_or("did:plc:4hqjfn7m6n5hno3doamuhgef");
.unwrap_or("ai.syui.log.user");
let collection_chat = oauth_config.get("collection_chat")
.and_then(|v| v.as_str())
.unwrap_or("ai.syui.log.chat");
// Extract AI config if present
let ai_config = config.get("ai")
.and_then(|v| v.as_table());
let ai_enabled = ai_config
.and_then(|ai_table| ai_table.get("enabled"))
.and_then(|ai| ai.get("enabled"))
.and_then(|v| v.as_bool())
.unwrap_or(true);
.unwrap_or(false);
let ai_ask_ai = ai_config
.and_then(|ai_table| ai_table.get("ask_ai"))
.and_then(|ai| ai.get("ask_ai"))
.and_then(|v| v.as_bool())
.unwrap_or(true);
.unwrap_or(false);
let ai_provider = ai_config
.and_then(|ai_table| ai_table.get("provider"))
.and_then(|ai| ai.get("provider"))
.and_then(|v| v.as_str())
.unwrap_or("ollama");
let ai_model = ai_config
.and_then(|ai_table| ai_table.get("model"))
.and_then(|ai| ai.get("model"))
.and_then(|v| v.as_str())
.unwrap_or("gemma3:4b");
.unwrap_or("gemma2:2b");
let ai_host = ai_config
.and_then(|ai_table| ai_table.get("host"))
.and_then(|ai| ai.get("host"))
.and_then(|v| v.as_str())
.unwrap_or("https://ollama.syui.ai");
let ai_system_prompt = ai_config
.and_then(|ai_table| ai_table.get("system_prompt"))
.and_then(|v| v.as_str())
.unwrap_or("あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。");
// Extract bsky_api from oauth config
let bsky_api = oauth_config.get("bsky_api")
.and_then(|v| v.as_str())
.unwrap_or("https://public.api.bsky.app");
// Extract atproto_api from oauth config
let atproto_api = oauth_config.get("atproto_api")
.and_then(|v| v.as_str())
.unwrap_or("https://bsky.social");
// 4. Create .env.production content
let env_content = format!(
@@ -98,8 +94,15 @@ VITE_OAUTH_CLIENT_ID={}/{}
VITE_OAUTH_REDIRECT_URI={}/{}
VITE_ADMIN_DID={}
# Base collection (all others are derived via getCollectionNames)
VITE_OAUTH_COLLECTION={}
# Collection names for OAuth app
VITE_COLLECTION_COMMENT={}
VITE_COLLECTION_USER={}
VITE_COLLECTION_CHAT={}
# Collection names for ailog (backward compatibility)
AILOG_COLLECTION_COMMENT={}
AILOG_COLLECTION_USER={}
AILOG_COLLECTION_CHAT={}
# AI Configuration
VITE_AI_ENABLED={}
@@ -107,27 +110,22 @@ VITE_AI_ASK_AI={}
VITE_AI_PROVIDER={}
VITE_AI_MODEL={}
VITE_AI_HOST={}
VITE_AI_SYSTEM_PROMPT="{}"
VITE_AI_DID={}
# API Configuration
VITE_BSKY_PUBLIC_API={}
VITE_ATPROTO_API={}
"#,
base_url,
base_url, client_id_path,
base_url, redirect_path,
admin_did,
collection_base,
collection_comment,
collection_user,
collection_chat,
collection_comment,
collection_user,
collection_chat,
ai_enabled,
ai_ask_ai,
ai_provider,
ai_model,
ai_host,
ai_system_prompt,
ai_did,
bsky_api,
atproto_api
ai_host
);
// 5. Find oauth directory (relative to current working directory)

View File

@@ -10,81 +10,18 @@ use std::process::{Command, Stdio};
use tokio::time::{sleep, Duration, interval};
use tokio_tungstenite::{connect_async, tungstenite::Message};
use toml;
use reqwest;
use super::auth::{load_config, load_config_with_refresh, AuthConfig};
#[derive(Debug, Clone)]
struct AiConfig {
blog_host: String,
ollama_host: String,
ai_did: String,
model: String,
system_prompt: String,
bsky_api: String,
}
impl Default for AiConfig {
fn default() -> Self {
Self {
blog_host: "https://syui.ai".to_string(),
ollama_host: "https://ollama.syui.ai".to_string(),
ai_did: "did:plc:4hqjfn7m6n5hno3doamuhgef".to_string(),
model: "gemma3:4b".to_string(),
system_prompt: "あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。相手のことが大好きで、ときどき甘えたり、照れたりします。でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。".to_string(),
bsky_api: "https://public.api.bsky.app".to_string(),
}
}
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct BlogPost {
title: String,
href: String,
#[serde(rename = "formated_time")]
#[allow(dead_code)]
date: String,
#[allow(dead_code)]
tags: Vec<String>,
#[allow(dead_code)]
contents: String,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct BlogIndex {
#[allow(dead_code)]
posts: Vec<BlogPost>,
}
#[derive(Debug, Serialize)]
struct OllamaRequest {
model: String,
prompt: String,
stream: bool,
options: OllamaOptions,
}
#[derive(Debug, Serialize)]
struct OllamaOptions {
temperature: f32,
top_p: f32,
num_predict: i32,
}
#[derive(Debug, Deserialize)]
struct OllamaResponse {
response: String,
}
// Load collection config with priority: env vars > project config.toml > defaults
fn load_collection_config(project_dir: Option<&Path>) -> Result<(String, String)> {
// 1. Check environment variables first (highest priority)
if let Ok(base_collection) = std::env::var("VITE_OAUTH_COLLECTION") {
if let (Ok(comment), Ok(user)) = (
std::env::var("AILOG_COLLECTION_COMMENT"),
std::env::var("AILOG_COLLECTION_USER")
) {
println!("{}", "📂 Using collection config from environment variables".cyan());
let collection_user = format!("{}.user", base_collection);
return Ok((base_collection, collection_user));
return Ok((comment, user));
}
// 2. Try to load from project config.toml (second priority)
@@ -123,93 +60,17 @@ fn load_collection_config_from_project(project_dir: &Path) -> Result<(String, St
.and_then(|v| v.as_table())
.ok_or_else(|| anyhow::anyhow!("No [oauth] section found in config.toml"))?;
// Use new simplified collection structure (base collection)
let collection_base = oauth_config.get("collection")
let collection_comment = oauth_config.get("collection_comment")
.and_then(|v| v.as_str())
.unwrap_or("ai.syui.log")
.to_string();
// Derive user collection from base
let collection_user = format!("{}.user", collection_base);
Ok((collection_base, collection_user))
}
// Load AI config from project's config.toml
fn load_ai_config_from_project() -> Result<AiConfig> {
// Try to find config.toml in current directory or parent directories
let mut current_dir = std::env::current_dir()?;
let mut config_path = None;
for _ in 0..5 { // Search up to 5 levels up
let potential_config = current_dir.join("config.toml");
if potential_config.exists() {
config_path = Some(potential_config);
break;
}
if !current_dir.pop() {
break;
}
}
let config_path = config_path.ok_or_else(|| anyhow::anyhow!("config.toml not found in current directory or parent directories"))?;
let config_content = fs::read_to_string(&config_path)
.with_context(|| format!("Failed to read config.toml from {}", config_path.display()))?;
let config: toml::Value = config_content.parse()
.with_context(|| "Failed to parse config.toml")?;
// Extract site config
let site_config = config.get("site").and_then(|v| v.as_table());
let blog_host = site_config
.and_then(|s| s.get("base_url"))
let collection_user = oauth_config.get("collection_user")
.and_then(|v| v.as_str())
.unwrap_or("https://syui.ai")
.unwrap_or("ai.syui.log.user")
.to_string();
// Extract AI config
let ai_config = config.get("ai").and_then(|v| v.as_table());
let ollama_host = ai_config
.and_then(|ai| ai.get("host"))
.and_then(|v| v.as_str())
.unwrap_or("https://ollama.syui.ai")
.to_string();
let ai_did = ai_config
.and_then(|ai| ai.get("ai_did"))
.and_then(|v| v.as_str())
.unwrap_or("did:plc:4hqjfn7m6n5hno3doamuhgef")
.to_string();
let model = ai_config
.and_then(|ai| ai.get("model"))
.and_then(|v| v.as_str())
.unwrap_or("gemma3:4b")
.to_string();
let system_prompt = ai_config
.and_then(|ai| ai.get("system_prompt"))
.and_then(|v| v.as_str())
.unwrap_or("あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。相手のことが大好きで、ときどき甘えたり、照れたりします。でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。")
.to_string();
// Extract OAuth config for bsky_api
let oauth_config = config.get("oauth").and_then(|v| v.as_table());
let bsky_api = oauth_config
.and_then(|oauth| oauth.get("bsky_api"))
.and_then(|v| v.as_str())
.unwrap_or("https://public.api.bsky.app")
.to_string();
Ok(AiConfig {
blog_host,
ollama_host,
ai_did,
model,
system_prompt,
bsky_api,
})
Ok((collection_comment, collection_user))
}
#[derive(Debug, Serialize, Deserialize)]
@@ -257,14 +118,15 @@ fn get_pid_file() -> Result<PathBuf> {
Ok(pid_dir.join("stream.pid"))
}
pub async fn start(project_dir: Option<PathBuf>, daemon: bool, ai_generate: bool) -> Result<()> {
pub async fn start(project_dir: Option<PathBuf>, daemon: bool) -> Result<()> {
let mut config = load_config_with_refresh().await?;
// Load collection config with priority: env vars > project config > defaults
let (collection_comment, _collection_user) = load_collection_config(project_dir.as_deref())?;
let (collection_comment, collection_user) = load_collection_config(project_dir.as_deref())?;
// Update config with loaded collections
config.collections.base = collection_comment.clone();
config.collections.comment = collection_comment.clone();
config.collections.user = collection_user;
config.jetstream.collections = vec![collection_comment];
let pid_file = get_pid_file()?;
@@ -289,11 +151,6 @@ pub async fn start(project_dir: Option<PathBuf>, daemon: bool, ai_generate: bool
args.push(project_path.to_string_lossy().to_string());
}
// Add ai_generate flag if enabled
if ai_generate {
args.push("--ai-generate".to_string());
}
let child = Command::new(current_exe)
.args(&args)
.stdin(Stdio::null())
@@ -335,19 +192,6 @@ pub async fn start(project_dir: Option<PathBuf>, daemon: bool, ai_generate: bool
let max_reconnect_attempts = 10;
let mut config = config; // Make config mutable for token refresh
// Start AI generation monitor if enabled
if ai_generate {
let ai_config = config.clone();
tokio::spawn(async move {
loop {
if let Err(e) = run_ai_generation_monitor(&ai_config).await {
println!("{}", format!("❌ AI generation monitor error: {}", e).red());
sleep(Duration::from_secs(60)).await; // Wait 1 minute before retry
}
}
});
}
loop {
match run_monitor(&mut config).await {
Ok(_) => {
@@ -500,7 +344,7 @@ async fn handle_message(text: &str, config: &mut AuthConfig) -> Result<()> {
if let (Some(collection), Some(commit), Some(did)) =
(&message.collection, &message.commit, &message.did) {
if collection == &config.collections.comment() && commit.operation.as_deref() == Some("create") {
if collection == &config.collections.comment && commit.operation.as_deref() == Some("create") {
let unknown_uri = "unknown".to_string();
let uri = commit.uri.as_ref().unwrap_or(&unknown_uri);
@@ -532,7 +376,6 @@ async fn handle_message(text: &str, config: &mut AuthConfig) -> Result<()> {
async fn resolve_handle(did: &str) -> Result<String> {
let client = reqwest::Client::new();
// Use default bsky API for handle resolution
let url = format!("https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor={}",
urlencoding::encode(did));
@@ -595,7 +438,7 @@ async fn get_current_user_list(config: &mut AuthConfig) -> Result<Vec<UserRecord
let url = format!("{}/xrpc/com.atproto.repo.listRecords?repo={}&collection={}&limit=10",
config.admin.pds,
urlencoding::encode(&config.admin.did),
urlencoding::encode(&config.collections.user()));
urlencoding::encode(&config.collections.user));
let response = client
.get(&url)
@@ -658,7 +501,7 @@ async fn post_user_list(config: &mut AuthConfig, users: &[UserRecord], metadata:
let rkey = format!("{}-{}", short_did, now.format("%Y-%m-%dT%H-%M-%S-%3fZ").to_string().replace(".", "-"));
let record = UserListRecord {
record_type: config.collections.user(),
record_type: config.collections.user.clone(),
users: users.to_vec(),
created_at: now.to_rfc3339(),
updated_by: UserInfo {
@@ -672,7 +515,7 @@ async fn post_user_list(config: &mut AuthConfig, users: &[UserRecord], metadata:
let request_body = json!({
"repo": config.admin.did,
"collection": config.collections.user(),
"collection": config.collections.user,
"rkey": rkey,
"record": record
});
@@ -916,7 +759,7 @@ async fn get_recent_comments(config: &mut AuthConfig) -> Result<Vec<Value>> {
let url = format!("{}/xrpc/com.atproto.repo.listRecords?repo={}&collection={}&limit=20",
config.admin.pds,
urlencoding::encode(&config.admin.did),
urlencoding::encode(&config.collections.comment()));
urlencoding::encode(&config.collections.comment));
if std::env::var("AILOG_DEBUG").is_ok() {
println!("{}", format!("🌐 API Request URL: {}", url).yellow());
@@ -997,7 +840,7 @@ pub async fn test_api() -> Result<()> {
println!("{}", format!("✅ Successfully retrieved {} comments", comments.len()).green());
if comments.is_empty() {
println!("{}", format!(" No comments found in {} collection", config.collections.comment()).blue());
println!("{}", format!(" No comments found in {} collection", config.collections.comment).blue());
println!("💡 Try posting a comment first using the web interface");
} else {
println!("{}", "📝 Comment details:".cyan());
@@ -1028,416 +871,5 @@ pub async fn test_api() -> Result<()> {
}
}
Ok(())
}
// AI content generation functions
async fn generate_ai_content(content: &str, prompt_type: &str, ai_config: &AiConfig) -> Result<String> {
let model = &ai_config.model;
let system_prompt = &ai_config.system_prompt;
let prompt = match prompt_type {
"translate" => format!(
"{}\n\n# 指示\n以下の日本語ブログ記事を英語に翻訳してください。\n- 技術用語やコードブロックはそのまま維持\n- アイらしい表現で翻訳\n- 簡潔に要点をまとめる\n\n# ブログ記事\n{}",
system_prompt, content
),
"comment" => {
// Limit content to first 500 characters to reduce input size
let limited_content = if content.len() > 500 {
format!("{}...", &content[..500])
} else {
content.to_string()
};
format!(
"{}\n\n# 指示\nこのブログ記事を読んで、アイらしい感想を一言でください。\n- 30文字以内の短い感想\n- 技術的な内容への素朴な驚きや発見\n- 「わー!」「すごい!」など、アイらしい感嘆詞で始める\n- 簡潔で分かりやすく\n\n# ブログ記事(要約)\n{}\n\n# 出力形式\n一言の感想のみ(説明や詳細は不要):",
system_prompt, limited_content
)
},
_ => return Err(anyhow::anyhow!("Unknown prompt type: {}", prompt_type)),
};
let num_predict = match prompt_type {
"comment" => 50, // Very short for comments (about 30-40 characters)
"translate" => 3000, // Much longer for translations
_ => 300,
};
let request = OllamaRequest {
model: model.to_string(),
prompt,
stream: false,
options: OllamaOptions {
temperature: 0.7, // Lower temperature for more focused responses
top_p: 0.8,
num_predict,
},
};
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(120)) // 2 minute timeout
.build()?;
// Try localhost first (for same-server deployment)
let localhost_url = "http://localhost:11434/api/generate";
match client.post(localhost_url).json(&request).send().await {
Ok(response) if response.status().is_success() => {
let ollama_response: OllamaResponse = response.json().await?;
println!("{}", "✅ Used localhost Ollama".green());
return Ok(ollama_response.response);
}
_ => {
println!("{}", "⚠️ Localhost Ollama not available, trying remote...".yellow());
}
}
// Fallback to remote host
let remote_url = format!("{}/api/generate", ai_config.ollama_host);
println!("{}", format!("🔗 Making request to: {} with Origin: {}", remote_url, ai_config.blog_host).blue());
let response = client
.post(&remote_url)
.header("Origin", &ai_config.blog_host)
.json(&request)
.send()
.await?;
if !response.status().is_success() {
return Err(anyhow::anyhow!("Ollama API request failed: {}", response.status()));
}
let ollama_response: OllamaResponse = response.json().await?;
println!("{}", "✅ Used remote Ollama".green());
Ok(ollama_response.response)
}
async fn run_ai_generation_monitor(config: &AuthConfig) -> Result<()> {
// Load AI config from project config.toml or use defaults
let ai_config = load_ai_config_from_project().unwrap_or_else(|e| {
println!("{}", format!("⚠️ Failed to load AI config: {}, using defaults", e).yellow());
AiConfig::default()
});
let blog_host = &ai_config.blog_host;
let ollama_host = &ai_config.ollama_host;
let ai_did = &ai_config.ai_did;
println!("{}", "🤖 Starting AI content generation monitor...".cyan());
println!("📡 Blog host: {}", blog_host);
println!("🧠 Ollama host: {}", ollama_host);
println!("🤖 AI DID: {}", ai_did);
println!();
let mut interval = interval(Duration::from_secs(300)); // Check every 5 minutes
let client = reqwest::Client::new();
loop {
interval.tick().await;
println!("{}", "🔍 Checking for new blog posts...".blue());
match check_and_process_new_posts(&client, config, &ai_config).await {
Ok(count) => {
if count > 0 {
println!("{}", format!("✅ Processed {} new posts", count).green());
} else {
println!("{}", " No new posts found".blue());
}
}
Err(e) => {
println!("{}", format!("❌ Error processing posts: {}", e).red());
}
}
println!("{}", "⏰ Waiting for next check...".cyan());
}
}
async fn check_and_process_new_posts(
client: &reqwest::Client,
config: &AuthConfig,
ai_config: &AiConfig,
) -> Result<usize> {
// Fetch blog index
let index_url = format!("{}/index.json", ai_config.blog_host);
let response = client.get(&index_url).send().await?;
if !response.status().is_success() {
return Err(anyhow::anyhow!("Failed to fetch blog index: {}", response.status()));
}
let blog_posts: Vec<BlogPost> = response.json().await?;
println!("{}", format!("📄 Found {} posts in blog index", blog_posts.len()).cyan());
// Get existing AI generated content from collections
let existing_lang_records = get_existing_records(config, &config.collections.chat_lang()).await?;
let existing_comment_records = get_existing_records(config, &config.collections.chat_comment()).await?;
let mut processed_count = 0;
for post in blog_posts {
let post_slug = extract_slug_from_url(&post.href);
// Check if translation already exists (support both old and new format)
let translation_exists = existing_lang_records.iter().any(|record| {
let value = record.get("value");
// Check new format: value.post.slug
let new_format_match = value
.and_then(|v| v.get("post"))
.and_then(|p| p.get("slug"))
.and_then(|s| s.as_str())
== Some(&post_slug);
// Check old format: value.post_slug
let old_format_match = value
.and_then(|v| v.get("post_slug"))
.and_then(|s| s.as_str())
== Some(&post_slug);
new_format_match || old_format_match
});
if translation_exists {
println!("{}", format!("⏭️ Translation already exists for: {}", post.title).yellow());
}
// Check if comment already exists (support both old and new format)
let comment_exists = existing_comment_records.iter().any(|record| {
let value = record.get("value");
// Check new format: value.post.slug
let new_format_match = value
.and_then(|v| v.get("post"))
.and_then(|p| p.get("slug"))
.and_then(|s| s.as_str())
== Some(&post_slug);
// Check old format: value.post_slug
let old_format_match = value
.and_then(|v| v.get("post_slug"))
.and_then(|s| s.as_str())
== Some(&post_slug);
new_format_match || old_format_match
});
if comment_exists {
println!("{}", format!("⏭️ Comment already exists for: {}", post.title).yellow());
}
// Generate translation if not exists
if !translation_exists {
match generate_and_store_translation(client, config, &post, ai_config).await {
Ok(_) => {
println!("{}", format!("✅ Generated translation for: {}", post.title).green());
processed_count += 1;
}
Err(e) => {
println!("{}", format!("❌ Failed to generate translation for {}: {}", post.title, e).red());
}
}
} else {
println!("{}", format!("⏭️ Translation already exists for: {}", post.title).yellow());
}
// Generate comment if not exists
if !comment_exists {
match generate_and_store_comment(client, config, &post, ai_config).await {
Ok(_) => {
println!("{}", format!("✅ Generated comment for: {}", post.title).green());
processed_count += 1;
}
Err(e) => {
println!("{}", format!("❌ Failed to generate comment for {}: {}", post.title, e).red());
}
}
} else {
println!("{}", format!("⏭️ Comment already exists for: {}", post.title).yellow());
}
}
Ok(processed_count)
}
async fn get_existing_records(config: &AuthConfig, collection: &str) -> Result<Vec<serde_json::Value>> {
let client = reqwest::Client::new();
let url = format!("{}/xrpc/com.atproto.repo.listRecords?repo={}&collection={}&limit=100",
config.admin.pds,
urlencoding::encode(&config.admin.did),
urlencoding::encode(collection));
let response = client
.get(&url)
.header("Authorization", format!("Bearer {}", config.admin.access_jwt))
.send()
.await?;
if !response.status().is_success() {
return Ok(Vec::new()); // Return empty if collection doesn't exist yet
}
let list_response: serde_json::Value = response.json().await?;
let records = list_response["records"].as_array().unwrap_or(&Vec::new()).clone();
Ok(records)
}
fn extract_slug_from_url(url: &str) -> String {
// Extract slug from URL like "/posts/2025-06-06-ailog.html"
url.split('/')
.last()
.unwrap_or("")
.trim_end_matches(".html")
.to_string()
}
fn extract_date_from_slug(slug: &str) -> String {
// Extract date from slug like "2025-06-14-blog" -> "2025-06-14T00:00:00Z"
if slug.len() >= 10 && slug.chars().nth(4) == Some('-') && slug.chars().nth(7) == Some('-') {
format!("{}T00:00:00Z", &slug[0..10])
} else {
chrono::Utc::now().format("%Y-%m-%dT00:00:00Z").to_string()
}
}
async fn get_ai_profile(client: &reqwest::Client, ai_config: &AiConfig) -> Result<serde_json::Value> {
let url = format!("{}/xrpc/app.bsky.actor.getProfile?actor={}",
ai_config.bsky_api, urlencoding::encode(&ai_config.ai_did));
let response = client
.get(&url)
.send()
.await?;
if !response.status().is_success() {
// Fallback to default AI profile
return Ok(serde_json::json!({
"did": ai_config.ai_did,
"handle": "yui.syui.ai",
"displayName": "ai",
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:4hqjfn7m6n5hno3doamuhgef/bafkreiaxkv624mffw3cfyi67ufxtwuwsy2mjw2ygezsvtd44ycbgkfdo2a@jpeg"
}));
}
let profile_data: serde_json::Value = response.json().await?;
Ok(serde_json::json!({
"did": ai_config.ai_did,
"handle": profile_data["handle"].as_str().unwrap_or("yui.syui.ai"),
"displayName": profile_data["displayName"].as_str().unwrap_or("ai"),
"avatar": profile_data["avatar"].as_str()
}))
}
async fn generate_and_store_translation(
client: &reqwest::Client,
config: &AuthConfig,
post: &BlogPost,
ai_config: &AiConfig,
) -> Result<()> {
// Generate translation using post content instead of just title
let content_to_translate = format!("Title: {}\n\n{}", post.title, post.contents);
let translation = generate_ai_content(&content_to_translate, "translate", ai_config).await?;
// Get AI profile information
let ai_author = get_ai_profile(client, ai_config).await?;
// Extract post metadata
let post_slug = extract_slug_from_url(&post.href);
let post_date = extract_date_from_slug(&post_slug);
// Store in ai.syui.log.chat.lang collection with new format
let record_data = serde_json::json!({
"$type": "ai.syui.log.chat.lang",
"post": {
"url": post.href,
"slug": post_slug,
"title": post.title,
"date": post_date,
"tags": post.tags,
"language": "ja"
},
"type": "en",
"text": translation,
"author": ai_author,
"createdAt": chrono::Utc::now().to_rfc3339()
});
store_atproto_record(client, config, &config.collections.chat_lang(), &record_data).await
}
async fn generate_and_store_comment(
client: &reqwest::Client,
config: &AuthConfig,
post: &BlogPost,
ai_config: &AiConfig,
) -> Result<()> {
// Generate comment using limited post content for brevity
let limited_contents = if post.contents.len() > 300 {
format!("{}...", &post.contents[..300])
} else {
post.contents.clone()
};
let content_to_comment = format!("Title: {}\n\n{}", post.title, limited_contents);
let comment = generate_ai_content(&content_to_comment, "comment", ai_config).await?;
// Get AI profile information
let ai_author = get_ai_profile(client, ai_config).await?;
// Extract post metadata
let post_slug = extract_slug_from_url(&post.href);
let post_date = extract_date_from_slug(&post_slug);
// Store in ai.syui.log.chat.comment collection with new format
let record_data = serde_json::json!({
"$type": "ai.syui.log.chat.comment",
"post": {
"url": post.href,
"slug": post_slug,
"title": post.title,
"date": post_date,
"tags": post.tags,
"language": "ja"
},
"type": "info",
"text": comment,
"author": ai_author,
"createdAt": chrono::Utc::now().to_rfc3339()
});
store_atproto_record(client, config, &config.collections.chat_comment(), &record_data).await
}
async fn store_atproto_record(
client: &reqwest::Client,
_config: &AuthConfig,
collection: &str,
record_data: &serde_json::Value,
) -> Result<()> {
// Always load fresh config to ensure we have valid tokens
let config = load_config_with_refresh().await?;
let url = format!("{}/xrpc/com.atproto.repo.putRecord", config.admin.pds);
let put_request = serde_json::json!({
"repo": config.admin.did,
"collection": collection,
"rkey": uuid::Uuid::new_v4().to_string(),
"record": record_data
});
let response = client
.post(&url)
.header("Authorization", format!("Bearer {}", config.admin.access_jwt))
.header("Content-Type", "application/json")
.json(&put_request)
.send()
.await?;
if !response.status().is_success() {
let error_text = response.text().await?;
return Err(anyhow::anyhow!("Failed to store record: {}", error_text));
}
Ok(())
}

View File

@@ -23,7 +23,6 @@ pub struct SiteConfig {
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct BuildConfig {
pub highlight_code: bool,
pub highlight_theme: Option<String>,
pub minify: bool,
}
@@ -147,7 +146,6 @@ impl Default for Config {
},
build: BuildConfig {
highlight_code: true,
highlight_theme: Some("Monokai".to_string()),
minify: false,
},
ai: Some(AiConfig {

View File

@@ -18,7 +18,7 @@ pub struct Generator {
impl Generator {
pub fn new(base_path: PathBuf, config: Config) -> Result<Self> {
let markdown_processor = MarkdownProcessor::new(config.build.highlight_code, config.build.highlight_theme.clone());
let markdown_processor = MarkdownProcessor::new(config.build.highlight_code);
let template_engine = TemplateEngine::new(base_path.join("templates"))?;
let ai_manager = if let Some(ref ai_config) = config.ai {
@@ -39,20 +39,6 @@ impl Generator {
ai_manager,
})
}
fn create_config_with_timestamp(&self) -> Result<serde_json::Value> {
let mut config_with_timestamp = serde_json::to_value(&self.config.site)?;
if let Some(config_obj) = config_with_timestamp.as_object_mut() {
config_obj.insert("build_timestamp".to_string(), serde_json::Value::String(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
.to_string()
));
}
Ok(config_with_timestamp)
}
pub async fn build(&self) -> Result<()> {
// Clean public directory
@@ -71,9 +57,6 @@ impl Generator {
// Generate index page
self.generate_index(&posts).await?;
// Generate JSON index for API access
self.generate_json_index(&posts).await?;
// Generate post pages
for post in &posts {
self.generate_post_page(post).await?;
@@ -201,17 +184,16 @@ impl Generator {
let html_content = self.markdown_processor.render(&content)?;
// Use filename (without extension) as URL slug to include date
let filename_slug = path.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("post")
.to_string();
// Still keep the slug field from frontmatter for other purposes
let frontmatter_slug = frontmatter.get("slug")
// Use slug from frontmatter if available, otherwise derive from filename
let slug = frontmatter.get("slug")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| filename_slug.clone());
.unwrap_or_else(|| {
path.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("post")
.to_string()
});
let mut post = Post {
title: frontmatter.get("title")
@@ -223,9 +205,8 @@ impl Generator {
.unwrap_or("")
.to_string(),
content: html_content,
slug: frontmatter_slug.clone(),
filename_slug: filename_slug.clone(),
url: format!("/posts/{}.html", filename_slug),
slug: slug.clone(),
url: format!("/posts/{}.html", slug),
tags: frontmatter.get("tags")
.and_then(|v| v.as_array())
.map(|arr| arr.iter()
@@ -252,7 +233,7 @@ impl Generator {
lang: "en".to_string(),
title: translated_title,
content: translated_html,
url: format!("/posts/{}-en.html", post.filename_slug),
url: format!("/posts/{}-en.html", post.slug),
}]);
}
Err(e) => eprintln!("Translation failed: {}", e),
@@ -278,7 +259,7 @@ impl Generator {
// Enhance posts with additional metadata for timeline view
let enhanced_posts: Vec<serde_json::Value> = posts.iter().map(|post| {
let excerpt = self.extract_excerpt(&post.content);
let markdown_url = format!("/posts/{}.md", post.filename_slug);
let markdown_url = format!("/posts/{}.md", post.slug);
let translation_url = if let Some(ref translations) = post.translations {
translations.first().map(|t| t.url.clone())
} else {
@@ -300,8 +281,7 @@ impl Generator {
}).collect();
let mut context = tera::Context::new();
let config_with_timestamp = self.create_config_with_timestamp()?;
context.insert("config", &config_with_timestamp);
context.insert("config", &self.config.site);
context.insert("posts", &enhanced_posts);
let html = self.template_engine.render("index.html", &context)?;
@@ -314,15 +294,14 @@ impl Generator {
async fn generate_post_page(&self, post: &Post) -> Result<()> {
let mut context = tera::Context::new();
let config_with_timestamp = self.create_config_with_timestamp()?;
context.insert("config", &config_with_timestamp);
context.insert("config", &self.config.site);
// Create enhanced post with additional URLs
let mut enhanced_post = post.clone();
enhanced_post.url = format!("/posts/{}.html", post.filename_slug);
enhanced_post.url = format!("/posts/{}.html", post.slug);
// Add markdown view URL
let markdown_url = format!("/posts/{}.md", post.filename_slug);
let markdown_url = format!("/posts/{}.md", post.slug);
// Add translation URLs if available
let translation_urls: Vec<String> = if let Some(ref translations) = post.translations {
@@ -349,7 +328,7 @@ impl Generator {
let output_dir = self.base_path.join("public/posts");
fs::create_dir_all(&output_dir)?;
let output_path = output_dir.join(format!("{}.html", post.filename_slug));
let output_path = output_dir.join(format!("{}.html", post.slug));
fs::write(output_path, html)?;
// Generate markdown view
@@ -360,8 +339,7 @@ impl Generator {
async fn generate_translation_page(&self, post: &Post, translation: &Translation) -> Result<()> {
let mut context = tera::Context::new();
let config_with_timestamp = self.create_config_with_timestamp()?;
context.insert("config", &config_with_timestamp);
context.insert("config", &self.config.site);
context.insert("post", &TranslatedPost {
title: translation.title.clone(),
date: post.date.clone(),
@@ -378,7 +356,7 @@ impl Generator {
let output_dir = self.base_path.join("public/posts");
fs::create_dir_all(&output_dir)?;
let output_path = output_dir.join(format!("{}-{}.html", post.filename_slug, translation.lang));
let output_path = output_dir.join(format!("{}-{}.html", post.slug, translation.lang));
fs::write(output_path, html)?;
Ok(())
@@ -435,11 +413,11 @@ impl Generator {
.unwrap_or("")
});
if file_slug == post.slug || path.file_stem().and_then(|s| s.to_str()).unwrap_or("") == post.filename_slug {
if file_slug == post.slug {
let output_dir = self.base_path.join("public/posts");
fs::create_dir_all(&output_dir)?;
let output_path = output_dir.join(format!("{}.md", post.filename_slug));
let output_path = output_dir.join(format!("{}.md", post.slug));
fs::write(output_path, content)?;
break;
}
@@ -449,63 +427,6 @@ impl Generator {
Ok(())
}
async fn generate_json_index(&self, posts: &[Post]) -> Result<()> {
let index_data: Vec<serde_json::Value> = posts.iter().map(|post| {
// Parse date for proper formatting
let parsed_date = chrono::NaiveDate::parse_from_str(&post.date, "%Y-%m-%d")
.unwrap_or_else(|_| chrono::Utc::now().naive_utc().date());
// Format to Hugo-style date format (Mon Jan 2, 2006)
let formatted_date = parsed_date.format("%a %b %-d, %Y").to_string();
// Create UTC datetime for utc_time field
let utc_datetime = parsed_date.and_hms_opt(0, 0, 0)
.unwrap_or_else(|| chrono::Utc::now().naive_utc());
let utc_time = format!("{}Z", utc_datetime.format("%Y-%m-%dT%H:%M:%S"));
// Extract plain text content from HTML
let contents = self.extract_plain_text(&post.content);
serde_json::json!({
"title": post.title,
"tags": post.tags,
"description": self.extract_excerpt(&post.content),
"categories": [],
"contents": contents,
"href": format!("{}{}", self.config.site.base_url.trim_end_matches('/'), post.url),
"utc_time": utc_time,
"formated_time": formatted_date
})
}).collect();
// Write JSON index to public directory
let output_path = self.base_path.join("public/index.json");
let json_content = serde_json::to_string_pretty(&index_data)?;
fs::write(output_path, json_content)?;
println!("{} JSON index with {} posts", "Generated".cyan(), posts.len());
Ok(())
}
fn extract_plain_text(&self, html_content: &str) -> String {
// Remove HTML tags and extract plain text
let mut text = String::new();
let mut in_tag = false;
for ch in html_content.chars() {
match ch {
'<' => in_tag = true,
'>' => in_tag = false,
_ if !in_tag => text.push(ch),
_ => {}
}
}
// Clean up whitespace
text.split_whitespace().collect::<Vec<_>>().join(" ")
}
}
#[derive(Debug, Clone, serde::Serialize)]
@@ -526,7 +447,6 @@ pub struct Post {
pub date: String,
pub content: String,
pub slug: String,
pub filename_slug: String, // Added for URL generation
pub url: String,
pub tags: Vec<String>,
pub translations: Option<Vec<Translation>>,
@@ -539,19 +459,4 @@ pub struct Translation {
pub title: String,
pub content: String,
pub url: String,
}
#[derive(Debug, Clone, serde::Serialize)]
#[allow(dead_code)]
struct BlogPost {
title: String,
url: String,
date: String,
}
#[derive(Debug, Clone, serde::Serialize)]
#[allow(dead_code)]
struct BlogIndex {
posts: Vec<BlogPost>,
}
}

View File

@@ -18,14 +18,10 @@ mod mcp;
#[derive(Parser)]
#[command(name = "ailog")]
#[command(about = "A static blog generator with AI features")]
#[command(disable_version_flag = true)]
#[command(version)]
struct Cli {
/// Print version information
#[arg(short = 'V', long = "version")]
version: bool,
#[command(subcommand)]
command: Option<Commands>,
command: Commands,
}
#[derive(Subcommand)]
@@ -46,9 +42,6 @@ enum Commands {
New {
/// Title of the post
title: String,
/// Slug for the post (optional, derived from title if not provided)
#[arg(short, long)]
slug: Option<String>,
/// Post format
#[arg(short, long, default_value = "md")]
format: String,
@@ -118,9 +111,6 @@ enum StreamCommands {
/// Run as daemon
#[arg(short, long)]
daemon: bool,
/// Enable AI content generation
#[arg(long)]
ai_generate: bool,
},
/// Stop monitoring
Stop,
@@ -142,28 +132,17 @@ enum OauthCommands {
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
// Handle version flag
if cli.version {
println!("{}", env!("CARGO_PKG_VERSION"));
return Ok(());
}
// Require subcommand if no version flag
let command = cli.command.ok_or_else(|| {
anyhow::anyhow!("No subcommand provided. Use --help for usage information.")
})?;
match command {
match cli.command {
Commands::Init { path } => {
commands::init::execute(path).await?;
}
Commands::Build { path } => {
commands::build::execute(path).await?;
}
Commands::New { title, slug, format, path } => {
Commands::New { title, format, path } => {
std::env::set_current_dir(path)?;
commands::new::execute(title, slug, format).await?;
commands::new::execute(title, format).await?;
}
Commands::Serve { port, path } => {
std::env::set_current_dir(path)?;
@@ -196,8 +175,8 @@ async fn main() -> Result<()> {
}
Commands::Stream { command } => {
match command {
StreamCommands::Start { project_dir, daemon, ai_generate } => {
commands::stream::start(project_dir, daemon, ai_generate).await?;
StreamCommands::Start { project_dir, daemon } => {
commands::stream::start(project_dir, daemon).await?;
}
StreamCommands::Stop => {
commands::stream::stop().await?;

View File

@@ -9,16 +9,14 @@ use serde_json::Value;
pub struct MarkdownProcessor {
highlight_code: bool,
highlight_theme: String,
syntax_set: SyntaxSet,
theme_set: ThemeSet,
}
impl MarkdownProcessor {
pub fn new(highlight_code: bool, highlight_theme: Option<String>) -> Self {
pub fn new(highlight_code: bool) -> Self {
Self {
highlight_code,
highlight_theme: highlight_theme.unwrap_or_else(|| "Monokai".to_string()),
syntax_set: SyntaxSet::load_defaults_newlines(),
theme_set: ThemeSet::load_defaults(),
}
@@ -88,19 +86,14 @@ impl MarkdownProcessor {
let parser = Parser::new_ext(content, options);
let mut html_output = String::new();
let mut code_block = None;
// Force use dark theme for better visibility on dark background
let theme = self.theme_set.themes.get("base16-monokai.dark")
.or_else(|| self.theme_set.themes.get("base16-ocean.dark"))
.or_else(|| self.theme_set.themes.get("Solarized (dark)"))
.or_else(|| self.theme_set.themes.get(&self.highlight_theme))
.unwrap_or_else(|| self.theme_set.themes.values().next().unwrap());
let theme = &self.theme_set.themes["base16-ocean.dark"];
let mut events = Vec::new();
for event in parser {
match event {
pulldown_cmark::Event::Start(pulldown_cmark::Tag::CodeBlock(kind)) => {
if let CodeBlockKind::Fenced(lang_info) = &kind {
code_block = Some((String::new(), lang_info.to_string()));
if let CodeBlockKind::Fenced(lang) = &kind {
code_block = Some((String::new(), lang.to_string()));
}
}
pulldown_cmark::Event::Text(text) => {
@@ -111,8 +104,8 @@ impl MarkdownProcessor {
}
}
pulldown_cmark::Event::End(pulldown_cmark::TagEnd::CodeBlock) => {
if let Some((code, lang_info)) = code_block.take() {
let highlighted = self.highlight_code_block(&code, &lang_info, theme);
if let Some((code, lang)) = code_block.take() {
let highlighted = self.highlight_code_block(&code, &lang, theme);
events.push(pulldown_cmark::Event::Html(highlighted.into()));
}
}
@@ -124,41 +117,13 @@ impl MarkdownProcessor {
Ok(html_output)
}
fn highlight_code_block(&self, code: &str, lang_info: &str, theme: &syntect::highlighting::Theme) -> String {
// Parse language and filename from lang_info (e.g., "sh:/path/to/file" or "rust:main.rs")
let (lang, filename) = if lang_info.contains(':') {
let parts: Vec<&str> = lang_info.splitn(2, ':').collect();
(parts[0], Some(parts[1]))
} else {
(lang_info, None)
};
// Map short language names to full names
let lang = match lang {
"rs" => "rust",
"js" => "javascript",
"ts" => "typescript",
"sh" => "bash",
"yml" => "yaml",
"md" => "markdown",
"py" => "python",
_ => lang,
};
fn highlight_code_block(&self, code: &str, lang: &str, theme: &syntect::highlighting::Theme) -> String {
let syntax = self.syntax_set
.find_syntax_by_token(lang)
.unwrap_or_else(|| self.syntax_set.find_syntax_plain_text());
let mut highlighter = syntect::easy::HighlightLines::new(syntax, theme);
// Create pre tag with optional filename attribute
let pre_tag = if let Some(filename) = filename {
format!("<pre data-filename=\"{}\">", filename)
} else {
"<pre>".to_string()
};
let mut output = format!("{}<code>", pre_tag);
let mut output = String::from("<pre><code>");
for line in code.lines() {
let ranges = highlighter.highlight_line(line, &self.syntax_set).unwrap();

View File

@@ -1,22 +0,0 @@
[Unit]
Description=ailog stream monitoring service
After=network.target
Wants=network.target
[Service]
Type=simple
User=syui
Group=syui
WorkingDirectory=/home/syui/git/log
ExecStart=/home/syui/.cargo/bin/ailog stream start my-blog
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
# Environment variables if needed
Environment=RUST_LOG=info
Environment=AILOG_DEBUG_ALL=1
[Install]
WantedBy=multi-user.target

View File

@@ -1,25 +0,0 @@
[Unit]
Description=Cloudflared tunnel for log.syui.ai
After=network.target
Wants=network.target
[Service]
Type=simple
User=syui
Group=syui
WorkingDirectory=/home/syui/git/log
ExecStart=/usr/bin/cloudflared tunnel --config /home/syui/git/log/cloudflared-config.yml run
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
# Security hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/home/syui/git/log
PrivateTmp=true
[Install]
WantedBy=multi-user.target