Compare commits
5 Commits
v0.1.6
...
721c9b4e71
Author | SHA1 | Date | |
---|---|---|---|
721c9b4e71
|
|||
efc73490d1
|
|||
1eda00f2d3
|
|||
ac4c3f2ad3
|
|||
2e0fe0edfc
|
@@ -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": []
|
||||
}
|
||||
|
@@ -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 }}
|
@@ -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
|
65
.github/workflows/cloudflare-pages.yml
vendored
65
.github/workflows/cloudflare-pages.yml
vendored
@@ -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: |
|
||||
|
92
.github/workflows/disabled/gh-pages-fast.yml
vendored
92
.github/workflows/disabled/gh-pages-fast.yml
vendored
@@ -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
|
169
.github/workflows/release.yml
vendored
169
.github/workflows/release.yml
vendored
@@ -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
3
.gitignore
vendored
@@ -10,6 +10,3 @@ my-blog/public/
|
||||
dist
|
||||
node_modules
|
||||
package-lock.json
|
||||
my-blog/static/assets/comment-atproto-*
|
||||
bin/ailog
|
||||
docs
|
||||
|
40
Cargo.toml
40
Cargo.toml
@@ -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"
|
128
action.yml
128
action.yml
@@ -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
|
@@ -1 +0,0 @@
|
||||
あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。
|
@@ -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.
@@ -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
|
14
claude.md
14
claude.md
@@ -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)の探求
|
||||
- **唯一性原則**: 現実の個人の唯一性をすべてのシステムで担保
|
||||
|
@@ -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"
|
||||
|
@@ -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!");
|
||||
```
|
||||
|
||||
|
@@ -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)
|
34
my-blog/content/posts/welcome.md
Normal file
34
my-blog/content/posts/welcome.md
Normal 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!
|
@@ -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 -}}
|
@@ -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
|
||||
|
||||
|
@@ -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 |
1
my-blog/static/assets/comment-atproto-B330B6QX.css
Normal file
1
my-blog/static/assets/comment-atproto-B330B6QX.css
Normal file
File diff suppressed because one or more lines are too long
122
my-blog/static/assets/comment-atproto-CDastf61.js
Normal file
122
my-blog/static/assets/comment-atproto-CDastf61.js
Normal file
File diff suppressed because one or more lines are too long
@@ -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
@@ -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 |
@@ -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 |
@@ -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">
|
@@ -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: '3' 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="$HOME/.cargo" $ export RUSTUP_HOME="$HOME/.rustup" $ export PATH="$HOME/.cargo/bin:$PATH" --- $ which ailog $ ailog -h build deploy $ cd my-blog $ vim config.toml $ ailog new test $ vim content/posts/`date +"%Y-%m-%d"`.md $ ailog build # publicの中身をweb-serverにdeploy $ cp -rf ./public/* ./web-server/root/ atproto-comment-system example $ 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です。 <script type="module" crossorigin src="/assets/comment-atproto-${hash}}.js"></script> <link rel="stylesheet" crossorigin href="/assets/comment-atproto-${hash}.css"> <section class="comment-section"> <div id="comment-atproto"></div> </section> ただし、oauthであるため、色々と大変です。本番環境(もしくは近い形)でテストを行いましょう。cf, tailscale, ngrokなど。 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!("Hello, world!"); } // This is a comment console.log("Hello, world!");",
|
||||
"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"
|
||||
}
|
||||
]
|
@@ -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;
|
@@ -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();
|
||||
});
|
@@ -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
@@ -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}
|
@@ -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}
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
Before Width: | Height: | Size: 58 KiB |
Binary file not shown.
Binary file not shown.
@@ -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";
|
||||
}
|
@@ -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 |
@@ -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>© {{ 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>
|
@@ -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>
|
||||
|
@@ -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">
|
@@ -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 %}
|
||||
|
@@ -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 %}
|
||||
|
@@ -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 %}
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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
@@ -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応答の生成に失敗しました' }
|
||||
}));
|
||||
|
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
@@ -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サーバーが必要です');
|
||||
}
|
||||
},
|
||||
|
@@ -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));
|
||||
}
|
||||
|
||||
|
@@ -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);
|
||||
}
|
||||
}
|
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -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()];
|
||||
}
|
@@ -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
|
||||
);
|
||||
|
@@ -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)
|
||||
|
@@ -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(())
|
||||
}
|
@@ -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 {
|
||||
|
141
src/generator.rs
141
src/generator.rs
@@ -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>,
|
||||
}
|
||||
|
||||
}
|
35
src/main.rs
35
src/main.rs
@@ -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?;
|
||||
|
@@ -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();
|
||||
|
@@ -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
|
@@ -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
|
Reference in New Issue
Block a user