62 Commits

Author SHA1 Message Date
b17ac3d91a v0.2.2: OAuth authentication system improvements
🔧 OAuth Fixes:
- Add transition:generic scope to resolve authentication errors
- Improve Agent creation with session object and dpopFetch fallback
- Fix avatar fetching to use correct public API endpoints
- Proper PDS endpoint selection (bsky.syu.is vs public.api.bsky.app)

🎨 UI Improvements:
- Remove 'Demo' text from loading states
- Environment-based feature toggles (TestUI/Debug)
- Unified padding system (20px 0)
- CSS conflict resolution with oauth- prefix

🚀 Production Ready:
- Automatic feature disable in production build
- Session management improvements
- Error handling enhancements

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-19 11:56:06 +09:00
81f87d0462 fix loading 2025-06-19 11:41:01 +09:00
a020fa24d8 fix gh-actions oauth-session 2025-06-19 11:34:56 +09:00
21c53010b7 test oauth_new gh-actions 2025-06-19 11:13:11 +09:00
4f7834f85c fix npm env production 2025-06-18 19:16:25 +09:00
fecd927b91 fix oauth_new env test 2025-06-18 18:16:37 +09:00
b54e8089ea oauth_new 2025-06-18 17:25:42 +09:00
174cb12d4d test merge 2025-06-18 10:53:48 +09:00
a1186f8185 Merge branch 'test-oauth' 2025-06-18 10:53:31 +09:00
833549756b fix did check 2025-06-17 22:36:33 +09:00
4edde5293a Add oauth_new: Complete OAuth authentication and comment system
- Created new oauth_new directory with clean OAuth implementation
- Added 4-tab interface: Lang, Comment, Collection, User Comments
- Implemented OAuth authentication with @atproto/oauth-client-browser
- Added comment posting functionality with putRecord
- Added proper PDS detection and error handling
- Skipped placeholder users to prevent errors
- Built comprehensive documentation (README.md, DEVELOPMENT.md)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-17 22:34:03 +09:00
f0fdf678c8 fix oauth plc 2025-06-17 17:43:03 +09:00
820e47f634 update binary 2025-06-17 11:01:42 +09:00
4dac4a83e0 fix atproto web link 2025-06-17 11:00:09 +09:00
fccf75949c v0.2.1: Fix async trait implementation warnings
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-17 10:42:15 +09:00
6600a9e0cf test pds oauth did 2025-06-17 10:41:22 +09:00
0d79af5aa5 v0.2.0: Unified AI content display and OAuth PDS fixes
Major Changes:
- Unified AI content rendering across all collection types (chat, lang, comment)
- Fixed PDS endpoint detection and usage based on handle configuration
- Removed hardcoded 'yui.syui.ai' references and used environment variables
- Fixed OAuth app 400 errors by adding null checks for API calls
- Improved AI DID resolution to use correct ai.syui.ai account
- Fixed avatar and profile link generation for correct PDS routing
- Enhanced network configuration mapping for different PDS types

OAuth App Improvements:
- Consolidated renderAIContent() function for all AI collections
- Fixed generateProfileUrl() to use PDS-specific web URLs
- Removed duplicate AI content rendering code
- Added proper error handling for API calls

Technical Fixes:
- Updated stream.rs to use correct AI DID defaults
- Improved CORS handling for Ollama localhost connections
- Enhanced PDS detection logic for handle-based routing
- Cleaned up production code (removed console.log statements)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-17 01:51:11 +09:00
db04af76ab test cleanup 2025-06-17 01:48:30 +09:00
5f0b09b555 add binary 2025-06-16 22:48:38 +09:00
8fa9e474d1 v0.1.9: Production deployment ready
🚀 Production Features
- Console output cleanup: Removed all console.log/warn/error from OAuth app
- Clean UI: Removed debug info divs from production build
- Warning-free builds: Fixed all Rust compilation warnings

🔧 Authentication & Stream Improvements
- Enhanced password authentication with PDS specification support
- Fixed AI DID resolution: Now correctly uses ai.syui.ai (did:plc:6qyecktefllvenje24fcxnie)
- Improved project directory config loading for ailog stream commands
- Added user list initialization commands with proper PDS detection

📚 Documentation
- Complete command reference in docs/commands.md
- Architecture documentation in docs/architecture.md
- Getting started guide in docs/getting-started.md

🛠️ Technical Improvements
- Project-aware AI config loading from config.toml
- Runtime DID resolution for OAuth app
- Proper handle/DID distinction across all components
- Enhanced error handling with clean user feedback

🔐 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-16 22:29:46 +09:00
5339dd28b0 test scpt 2025-06-16 22:27:20 +09:00
1e83b50e3f test cli stream 2025-06-16 22:09:04 +09:00
889ce8baa1 test oauth pds 2025-06-16 20:45:55 +09:00
286b46c6e6 fix systemd 2025-06-16 12:17:42 +09:00
b780d27ace update binary 2025-06-16 12:17:29 +09:00
831fcb7865 v0.1.8: Enhanced OAuth search bar and configurable AI settings
- Transform auth-section to search bar layout (input left, button right)
- Change atproto button text to "@" symbol
- Add num_predict configuration in config.toml for AI response length
- Improve mobile responsiveness for auth section
- Remove auth-status section for cleaner UI

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-16 11:45:24 +09:00
3f8bbff7c2 fix layout oauth-bar 2025-06-16 11:43:15 +09:00
5cb73a9ed3 add scpt 2025-06-16 10:55:30 +09:00
6ce8d44c4b cleanup 2025-06-16 10:53:42 +09:00
167cfb35f7 fix tab name 2025-06-16 02:29:37 +09:00
c8377ceabf rm auth-status 2025-06-16 02:25:00 +09:00
e917c563f2 update layout 2025-06-16 02:21:26 +09:00
a76933c23b cleanup 2025-06-16 01:16:36 +09:00
8d960b7a40 fix ask-ai enter 2025-06-15 23:33:22 +09:00
d3967c782f rm html 2025-06-15 23:23:12 +09:00
63b6fd5142 fix ai handle 2025-06-15 23:21:15 +09:00
27935324c7 fix mobile css 2025-06-15 22:56:34 +09:00
594d7e7aef v0.1.7: Enhanced UI and accessibility improvements
- Add CSS styling for chat messages with theme color border
- Fix comment form visibility (only show on Comments tab)
- Remove comment form heading for cleaner UI
- Add accessibility attributes (id/name) to all form fields
- Fix Japanese input handling in Ask AI (prevent accidental submission during IME composition)
- Unified CSS classes across all content types (comments, AI chat, translations)
- Fix rkey filtering to handle .html extensions consistently

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

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

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-14 19:52:08 +09:00
13f1785081 update 2025-06-14 15:56:25 +09:00
bb6d51a602 fix css 2025-06-14 13:17:09 +09:00
a4114c5be3 fix theme 2025-06-14 13:17:09 +09:00
5c13dc0a1c fix readme 2025-06-14 13:17:09 +09:00
cef0675a88 add system 2025-06-14 13:17:09 +09:00
fd223290df code layout 2025-06-14 13:17:09 +09:00
5f4382911b fix command build 2025-06-14 13:17:09 +09:00
95cee69482 add github 2025-06-14 13:17:08 +09:00
33c166fa0c fix color 2025-06-14 13:17:08 +09:00
36863e4d9f fix loading 2025-06-14 13:17:08 +09:00
fb0e5107cf add ask AI 2025-06-14 13:17:08 +09:00
171 changed files with 16102 additions and 3214 deletions

View File

@@ -37,7 +37,23 @@
"Bash(rg:*)",
"Bash(../target/release/ailog build)",
"Bash(zsh run.zsh:*)",
"Bash(hugo:*)"
"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:*)",
"WebFetch(domain:plc.directory)",
"WebFetch(domain:atproto.com)",
"WebFetch(domain:syu.is)",
"Bash(sed:*)",
"Bash(./scpt/run.zsh:*)"
],
"deny": []
}

View File

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

View File

@@ -0,0 +1,28 @@
name: Example ailog usage
on:
workflow_dispatch: # Manual trigger for testing
jobs:
build-with-ailog-action:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build with ailog action
uses: ai/log@v1 # This will reference this repository
with:
content-dir: 'content'
output-dir: 'public'
ai-integration: true
atproto-integration: true
- name: Deploy to Cloudflare Pages
uses: cloudflare/pages-action@v1
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
projectName: my-blog
directory: public

View File

@@ -6,6 +6,10 @@ on:
- main
workflow_dispatch:
env:
OAUTH_DIR: oauth_new
KEEP_DEPLOYMENTS: 5
jobs:
deploy:
runs-on: ubuntu-latest
@@ -24,32 +28,73 @@ jobs:
- name: Install dependencies
run: |
cd oauth
cd ${{ env.OAUTH_DIR }}
npm install
- name: Build OAuth app
run: |
cd oauth
npm run build
cd ${{ env.OAUTH_DIR }}
NODE_ENV=production npm run build
- name: Copy OAuth build to static
run: |
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
rm -rf my-blog/static/assets
cp -rf ${{ env.OAUTH_DIR }}/dist/* my-blog/static/
cp ${{ env.OAUTH_DIR }}/dist/index.html my-blog/templates/oauth-assets.html
- name: Setup Rust
uses: actions-rs/toolchain@v1
- name: Cache ailog binary
uses: actions/cache@v4
with:
toolchain: stable
- name: Build ailog
run: cargo build --release
path: ./bin
key: ailog-bin-${{ runner.os }}
restore-keys: |
ailog-bin-${{ runner.os }}
- name: Setup ailog binary
run: |
# Get expected version from Cargo.toml
EXPECTED_VERSION=$(grep '^version' Cargo.toml | cut -d'"' -f2)
echo "Expected version from Cargo.toml: $EXPECTED_VERSION"
# Check current binary version if exists
if [ -f "./bin/ailog" ]; then
CURRENT_VERSION=$(./bin/ailog --version 2>/dev/null || echo "unknown")
echo "Current binary version: $CURRENT_VERSION"
else
CURRENT_VERSION="none"
echo "No binary found"
fi
# Check OS
OS="${{ runner.os }}"
echo "Runner OS: $OS"
# Use pre-packaged binary if version matches or extract from tar.gz
if [ "$CURRENT_VERSION" = "$EXPECTED_VERSION" ]; then
echo "Binary is up to date"
chmod +x ./bin/ailog
elif [ "$OS" = "Linux" ] && [ -f "./bin/ailog-linux-x86_64.tar.gz" ]; then
echo "Extracting ailog from pre-packaged tar.gz..."
cd bin
tar -xzf ailog-linux-x86_64.tar.gz
chmod +x ailog
cd ..
# Verify extracted version
EXTRACTED_VERSION=$(./bin/ailog --version 2>/dev/null || echo "unknown")
echo "Extracted binary version: $EXTRACTED_VERSION"
if [ "$EXTRACTED_VERSION" != "$EXPECTED_VERSION" ]; then
echo "Warning: Binary version mismatch. Expected $EXPECTED_VERSION but got $EXTRACTED_VERSION"
fi
else
echo "Error: No suitable binary found for OS: $OS"
exit 1
fi
- name: Build site with ailog
run: |
cd my-blog
../target/release/ailog build
../bin/ailog build
- name: List public directory
run: |
@@ -64,3 +109,49 @@ jobs:
directory: my-blog/public
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
wranglerVersion: '3'
cleanup:
needs: deploy
runs-on: ubuntu-latest
if: success()
steps:
- name: Wait for deployment to complete
run: sleep 3
- name: Cleanup old deployments
run: |
# Get all deployments
DEPLOYMENTS=$(curl -s -X GET \
"https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${{ secrets.CLOUDFLARE_PROJECT_NAME }}/deployments" \
-H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \
-H "Content-Type: application/json")
# Extract deployment IDs (skip the latest N deployments)
DEPLOYMENT_IDS=$(echo "$DEPLOYMENTS" | jq -r ".result | sort_by(.created_on) | reverse | .[${{ env.KEEP_DEPLOYMENTS }}:] | .[].id // empty")
if [ -z "$DEPLOYMENT_IDS" ]; then
echo "No old deployments to delete"
exit 0
fi
# Delete old deployments
for ID in $DEPLOYMENT_IDS; do
echo "Deleting deployment: $ID"
RESPONSE=$(curl -s -X DELETE \
"https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${{ secrets.CLOUDFLARE_PROJECT_NAME }}/deployments/$ID" \
-H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \
-H "Content-Type: application/json")
SUCCESS=$(echo "$RESPONSE" | jq -r '.success')
if [ "$SUCCESS" = "true" ]; then
echo "Successfully deleted deployment: $ID"
else
echo "Failed to delete deployment: $ID"
echo "$RESPONSE" | jq .
fi
sleep 1 # Rate limiting
done
echo "Cleanup completed!"

View File

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

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

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

10
.gitignore vendored
View File

@@ -5,8 +5,16 @@
*.swo
*~
.DS_Store
cloudflare-config.yml
my-blog/public/
dist
node_modules
package-lock.json
my-blog/static/assets/comment-atproto-*
bin/ailog
docs
my-blog/static/index.html
my-blog/templates/oauth-assets.html
cloudflared-config.yml
.config
oauth-server-example
atproto

View File

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

View File

@@ -1,32 +0,0 @@
# Multi-stage build for ailog
FROM rust:1.75 as builder
WORKDIR /usr/src/app
COPY Cargo.toml Cargo.lock ./
COPY src ./src
RUN cargo build --release
FROM debian:bookworm-slim
# Install runtime dependencies
RUN apt-get update && apt-get install -y \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy the binary
COPY --from=builder /usr/src/app/target/release/ailog /usr/local/bin/ailog
# Copy blog content
COPY my-blog ./blog
# Build static site
RUN ailog build blog
# Expose port
EXPOSE 8080
# Run server
CMD ["ailog", "serve", "blog"]

908
README.md

File diff suppressed because it is too large Load Diff

Binary file not shown.

222
claude.md
View File

@@ -1,5 +1,227 @@
# エコシステム統合設計書
## 注意事項
`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
```
## oauth appの設計
> ./oauth/.env.production
```sh
VITE_ATPROTO_PDS=syu.is
VITE_ADMIN_HANDLE=ai.syui.ai
VITE_AI_HANDLE=ai.syui.ai
VITE_OAUTH_COLLECTION=ai.syui.log
```
これらは非常にシンプルな流れになっており、すべての項目は、共通します。短縮できる場合があります。handleは変わる可能性があるので、できる限りdidを使いましょう。
1. handleからpds, didを取得できる ... com.atproto.repo.describeRepo
2. pdsが分かれば、pdsApi, bskyApi, plcApiを割り当てられる
3. bskyApiが分かれば、getProfileでavatar-uriを取得できる ... app.bsky.actor.getProfile
4. pdsAPiからアカウントにあるcollectionのrecordの情報を取得できる ... com.atproto.repo.listRecords
### コメントを表示する
1. VITE_ADMIN_HANDLEから管理者のhandleを取得する。
2. VITE_ATPROTO_PDSから管理者のアカウントのpdsを取得する。
3. pdsからpdsApi, bskApi, plcApiを割り当てる。
```rust
match pds {
"bsky.social" | "bsky.app" => NetworkConfig {
pds_api: format!("https://{}", pds),
plc_api: "https://plc.directory".to_string(),
bsky_api: "https://public.api.bsky.app".to_string(),
web_url: "https://bsky.app".to_string(),
},
"syu.is" => NetworkConfig {
pds_api: "https://syu.is".to_string(),
plc_api: "https://plc.syu.is".to_string(),
bsky_api: "https://bsky.syu.is".to_string(),
web_url: "https://web.syu.is".to_string(),
},
_ => {
// Default to Bluesky network for unknown PDS
NetworkConfig {
pds_api: format!("https://{}", pds),
plc_api: "https://plc.directory".to_string(),
bsky_api: "https://public.api.bsky.app".to_string(),
web_url: "https://bsky.app".to_string(),
}
}
}
```
4. 管理者アカウントであるVITE_ADMIN_HANDLEとVITE_ATPROTO_PDSから`ai.syui.log.user`というuserlistを取得する。
```sh
curl -sL "https://${VITE_ATPROTO_PDS}/xrpc/com.atproto.repo.listRecords?repo=${VITE_ADMIN_HANDLE}&collection=ai.syui.log.user"
---
syui.ai
```
5. ユーザーがわかったら、そのユーザーのpdsを判定する。
```sh
curl -sL "https://bsky.social/xrpc/com.atproto.repo.describeRepo?repo=syui.ai" |jq -r ".didDoc.service.[].serviceEndpoint"
---
https://shiitake.us-east.host.bsky.network
curl -sL "https://bsky.social/xrpc/com.atproto.repo.describeRepo?repo=syui.ai" |jq -r ".did"
---
did:plc:uqzpqmrjnptsxezjx4xuh2mn
```
6. pdsからpdsApi, bskApi, plcApiを割り当てる。
```rust
match pds {
"bsky.social" | "bsky.app" => NetworkConfig {
pds_api: format!("https://{}", pds),
plc_api: "https://plc.directory".to_string(),
bsky_api: "https://public.api.bsky.app".to_string(),
web_url: "https://bsky.app".to_string(),
},
"syu.is" => NetworkConfig {
pds_api: "https://syu.is".to_string(),
plc_api: "https://plc.syu.is".to_string(),
bsky_api: "https://bsky.syu.is".to_string(),
web_url: "https://web.syu.is".to_string(),
},
_ => {
// Default to Bluesky network for unknown PDS
NetworkConfig {
pds_api: format!("https://{}", pds),
plc_api: "https://plc.directory".to_string(),
bsky_api: "https://public.api.bsky.app".to_string(),
web_url: "https://bsky.app".to_string(),
}
}
}
```
7. ユーザーの情報を取得、表示する
```sh
bsky_api=https://public.api.bsky.app
user_did=did:plc:uqzpqmrjnptsxezjx4xuh2mn
curl -sL "$bsky_api/xrpc/app.bsky.actor.getProfile?actor=$user_did"|jq -r .avatar
---
https://cdn.bsky.app/img/avatar/plain/did:plc:uqzpqmrjnptsxezjx4xuh2mn/bafkreid6kcc5pnn4b3ar7mj6vi3eiawhxgkcrw3edgbqeacyrlnlcoetea@jpeg
```
### AIの情報を表示する
AIが持つ`ai.syui.log.chat.lang`, `ai.syui.log.chat.comment`を表示します。
なお、これは通常、`VITE_ADMIN_HANDLE`にputRecordされます。そこから情報を読み込みます。`VITE_AI_HANDLE`はそのrecordの`author`のところに入ります。
```json
"author": {
"did": "did:plc:4hqjfn7m6n5hno3doamuhgef",
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:4hqjfn7m6n5hno3doamuhgef/bafkreiaxkv624mffw3cfyi67ufxtwuwsy2mjw2ygezsvtd44ycbgkfdo2a@jpeg",
"handle": "yui.syui.ai",
"displayName": "ai"
}
```
1. VITE_ADMIN_HANDLEから管理者のhandleを取得する。
2. VITE_ATPROTO_PDSから管理者のアカウントのpdsを取得する。
3. pdsからpdsApi, bskApi, plcApiを割り当てる。
```rust
match pds {
"bsky.social" | "bsky.app" => NetworkConfig {
pds_api: format!("https://{}", pds),
plc_api: "https://plc.directory".to_string(),
bsky_api: "https://public.api.bsky.app".to_string(),
web_url: "https://bsky.app".to_string(),
},
"syu.is" => NetworkConfig {
pds_api: "https://syu.is".to_string(),
plc_api: "https://plc.syu.is".to_string(),
bsky_api: "https://bsky.syu.is".to_string(),
web_url: "https://web.syu.is".to_string(),
},
_ => {
// Default to Bluesky network for unknown PDS
NetworkConfig {
pds_api: format!("https://{}", pds),
plc_api: "https://plc.directory".to_string(),
bsky_api: "https://public.api.bsky.app".to_string(),
web_url: "https://bsky.app".to_string(),
}
}
}
```
4. 管理者アカウントであるVITE_ADMIN_HANDLEとVITE_ATPROTO_PDSから`ai.syui.log.chat.lang`, `ai.syui.log.chat.comment`を取得する。
```sh
curl -sL "https://${VITE_ATPROTO_PDS}/xrpc/com.atproto.repo.listRecords?repo=${VITE_ADMIN_HANDLE}&collection=ai.syui.log.chat.comment"
```
5. AIのprofileを取得する。
```sh
curl -sL "https://${VITE_ATPROTO_PDS}/xrpc/com.atproto.repo.describeRepo?repo=$VITE_AI_HANDLE" |jq -r ".didDoc.service.[].serviceEndpoint"
---
https://syu.is
curl -sL "https://${VITE_ATPROTO_PDS}/xrpc/com.atproto.repo.describeRepo?repo=$VITE_AI_HANDLE" |jq -r ".did"
did:plc:6qyecktefllvenje24fcxnie
```
6. pdsからpdsApi, bskApi, plcApiを割り当てる。
```rust
match pds {
"bsky.social" | "bsky.app" => NetworkConfig {
pds_api: format!("https://{}", pds),
plc_api: "https://plc.directory".to_string(),
bsky_api: "https://public.api.bsky.app".to_string(),
web_url: "https://bsky.app".to_string(),
},
"syu.is" => NetworkConfig {
pds_api: "https://syu.is".to_string(),
plc_api: "https://plc.syu.is".to_string(),
bsky_api: "https://bsky.syu.is".to_string(),
web_url: "https://web.syu.is".to_string(),
},
_ => {
// Default to Bluesky network for unknown PDS
NetworkConfig {
pds_api: format!("https://{}", pds),
plc_api: "https://plc.directory".to_string(),
bsky_api: "https://public.api.bsky.app".to_string(),
web_url: "https://bsky.app".to_string(),
}
}
}
```
7. AIの情報を取得、表示する
```sh
bsky_api=https://bsky.syu.is
user_did=did:plc:6qyecktefllvenje24fcxnie
curl -sL "$bsky_api/xrpc/app.bsky.actor.getProfile?actor=$user_did"|jq -r .avatar
---
https://bsky.syu.is/img/avatar/plain/did:plc:6qyecktefllvenje24fcxnie/bafkreiet4pwlnshk7igra5flf2fuxpg2bhvf2apts4rqwcr56hzhgycii4@jpeg
```
## 中核思想
- **存在子理論**: この世界で最も小さいもの(存在子/aiの探求
- **唯一性原則**: 現実の個人の唯一性をすべてのシステムで担保

View File

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

View File

@@ -7,6 +7,7 @@ author = "syui"
[build]
highlight_code = true
highlight_theme = "Monokai"
minify = false
[ai]
@@ -15,15 +16,18 @@ auto_translate = false
comment_moderation = false
ask_ai = true
provider = "ollama"
model = "gemma3:2b"
host = "https://ollama.syui.ai"
system_prompt = "you are a helpful ai assistant trained on this blog's content. you can answer questions about the articles, provide insights, and help users understand the topics discussed."
ai_did = "did:plc:4hqjfn7m6n5hno3doamuhgef"
model = "qwen3"
model_translation = "llama3.2:1b"
model_technical = "phi3:mini"
host = "http://localhost:11434"
system_prompt = "あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"
handle = "ai.syui.ai"
#num_predict = 200
[oauth]
json = "client-metadata.json"
redirect = "oauth/callback"
admin = "did:plc:uqzpqmrjnptsxezjx4xuh2mn"
collection_comment = "ai.syui.log"
collection_user = "ai.syui.log.user"
collection_chat = "ai.syui.log.chat"
admin = "ai.syui.ai"
collection = "ai.syui.log"
pds = "syu.is"
handle_list = ["syui.syui.ai", "ai.syui.ai", "ai.ai"]

View File

@@ -1,18 +1,14 @@
---
title: "静的サイトジェネレータを作った"
slug: "ailog-system-introduction"
slug: "ailog"
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`からの移行になります。
ブログを書く環境もこれから変わってくると思っていて、例えば、`docs`, `readme`, `blog`などはAIが生成、または支援することになるだろうと予測しています。langの自動生成もAIが担当することになるでしょう
これは、音声に限らず、プログラミング言語から、osなど、様々なtranslateがAIの自動生成になるかもしれません。
`ailog`は、最初にatproto-comment-system(oauth)とask-AIというAI機能をつけました。
`ailog`は、最初にatproto-comment-system(oauth)とask-AIという機能をつけました
## quick start
@@ -21,7 +17,7 @@ $ git clone https://git.syui.ai/ai/log
$ cd log
$ cargo build
$ ./target/debug/ailog init my-blog
$ ./target/debug/ailog server my-blog
$ ./target/debug/ailog serve my-blog
```
## install
@@ -34,7 +30,7 @@ $ export RUSTUP_HOME="$HOME/.rustup"
$ export PATH="$HOME/.cargo/bin:$PATH"
---
$ which ailog
$ ailog
$ ailog -h
```
## build deploy
@@ -61,27 +57,37 @@ $ npm run build
$ npm run preview
```
```sh
```sh:ouath/.env.production
# 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_APP_HOST=https://syui.ai
VITE_OAUTH_CLIENT_ID=https://syui.ai/client-metadata.json
VITE_OAUTH_REDIRECT_URI=https://syui.ai/oauth/callback
VITE_ADMIN_DID=did:plc:uqzpqmrjnptsxezjx4xuh2mn
# 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
# Base collection (all others are derived via getCollectionNames)
VITE_OAUTH_COLLECTION=ai.syui.log
# Collection names for ailog (backward compatibility)
AILOG_COLLECTION_COMMENT=ai.syui.log
AILOG_COLLECTION_USER=ai.syui.log.user
# AI Configuration
VITE_AI_ENABLED=true
VITE_AI_ASK_AI=true
VITE_AI_PROVIDER=ollama
VITE_AI_MODEL=gemma3:4b
VITE_AI_HOST=https://ollama.syui.ai
VITE_AI_SYSTEM_PROMPT="ai"
VITE_AI_DID=did:plc:4hqjfn7m6n5hno3doamuhgef
# API Configuration
VITE_BSKY_PUBLIC_API=https://public.api.bsky.app
VITE_ATPROTO_API=https://bsky.social
```
### 解説
これは`ailog oauth build my-blog`で`./my-blog/config.toml`から`./oauth/.env.production`が生成されます。
```sh
$ ailog oauth build my-blog
```
### use
簡単に説明すると、`./oauth`で生成するのが`atproto-comment-system`です。
@@ -113,15 +119,8 @@ $ cloudflared tunnel --config cloudflared-config.yml run
$ cloudflared tunnel route dns ${uuid} example.com
```
以下の2つのcollection recordを生成します。ユーザーには`ai.syui.log`が生成され、ここにコメントが記録されます。それを取得して表示しています。`ai.syui.log.user`は管理者である`VITE_ADMIN_DID`用です。
```sh
VITE_COLLECTION_COMMENT=ai.syui.log
VITE_COLLECTION_USER=ai.syui.log.user
```
```sh
$ ailog auth login
$ ailog auth init
$ ailog stream server
```
@@ -133,5 +132,26 @@ $ ailog stream server
`ask-AI`の仕組みは割愛します。後に変更される可能性が高いと思います。
local llm, mcp, atproto組み合わせです。
`llm`, `mcp`, `atproto`などの組み合わせです。
現在、`/index.json`を監視して、更新があれば、翻訳などを行い自動ポストする機能があります。
## code syntax
```zsh:/path/to/test.zsh
# comment
d=${0:a:h}
```
```rust:/path/to/test.rs
// This is a comment
fn main() {
println!("Hello, world!");
}
```
```js:/path/to/test.js
// This is a comment
console.log("Hello, world!");
```

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
{{ $dateFormat := default "Mon Jan 2, 2006" (index .Site.Params "date_format") }}
{{ $utcFormat := "2006-01-02T15:04:05Z07:00" }}
{{- $.Scratch.Add "index" slice -}}
{{- range .Site.RegularPages -}}
{{- $.Scratch.Add "index" (dict "title" .Title "tags" .Params.tags "description" .Description "categories" .Params.categories "contents" .Plain "href" .Permalink "utc_time" (.Date.Format $utcFormat) "formated_time" (.Date.Format $dateFormat)) -}}
{{- end -}}
{{- $.Scratch.Get "index" | jsonify -}}

View File

@@ -0,0 +1,20 @@
# 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
# Handle-based Configuration (DIDs resolved at runtime)
VITE_ATPROTO_PDS=syu.is
VITE_ADMIN_HANDLE=ai.syui.ai
VITE_AI_HANDLE=ai.syui.ai
VITE_OAUTH_COLLECTION=ai.syui.log
VITE_ATPROTO_WEB_URL=https://bsky.app
VITE_ATPROTO_HANDLE_LIST=["syui.syui.ai", "ai.syui.ai", "ai.ai"]
# AI Configuration
VITE_AI_ENABLED=true
VITE_AI_ASK_AI=true
VITE_AI_PROVIDER=ollama
VITE_AI_MODEL=qwen3
VITE_AI_HOST=http://localhost:11434
VITE_AI_SYSTEM_PROMPT="あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"

View File

@@ -16,11 +16,32 @@
Cache-Control: public, max-age=31536000, immutable
/css/*
Cache-Control: public, max-age=31536000, immutable
Content-Type: text/css
Cache-Control: no-cache
/*.js
Content-Type: application/javascript
Cache-Control: public, max-age=31536000, immutable
/assets/*.js
Content-Type: application/javascript
Cache-Control: public, max-age=31536000, immutable
# Ensure ES6 modules are served correctly
/assets/comment-atproto-*.js
Content-Type: text/javascript; charset=utf-8
Cache-Control: public, max-age=31536000, immutable
# All JS assets
/assets/*-*.js
Content-Type: text/javascript; charset=utf-8
Cache-Control: public, max-age=31536000, immutable
# CSS assets
/assets/*.css
Content-Type: text/css
Cache-Control: public, max-age=60
/posts/*
Cache-Control: public, max-age=3600

View File

@@ -1,9 +1,3 @@
# 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.

After

Width:  |  Height:  |  Size: 23 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,13 +1,13 @@
{
"client_id": "https://log.syui.ai/client-metadata.json",
"client_name": "ai.card",
"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",
"client_id": "https://syui.ai/client-metadata.json",
"client_name": "ai.log",
"client_uri": "https://syui.ai",
"logo_uri": "https://syui.ai/favicon.ico",
"tos_uri": "https://syui.ai/terms",
"policy_uri": "https://syui.ai/privacy",
"redirect_uris": [
"https://log.syui.ai/oauth/callback",
"https://log.syui.ai/"
"https://syui.ai/oauth/callback",
"https://syui.ai/"
],
"response_types": [
"code"
@@ -21,4 +21,4 @@
"subject_type": "public",
"application_type": "web",
"dpop_bound_access_tokens": true
}
}

File diff suppressed because it is too large Load Diff

View File

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

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

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

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

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

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

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

@@ -0,0 +1,295 @@
/**
* 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);
});
// Track IME composition state
let isComposing = false;
const aiQuestionInput = document.getElementById('aiQuestion');
if (aiQuestionInput) {
aiQuestionInput.addEventListener('compositionstart', function() {
isComposing = true;
});
aiQuestionInput.addEventListener('compositionend', function() {
isComposing = false;
});
}
// Keyboard shortcuts
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
const panel = document.getElementById('askAiPanel');
if (panel) {
panel.style.display = 'none';
}
}
// Enter key to send message (only when not composing Japanese input)
if (e.key === 'Enter' && e.target.id === 'aiQuestion' && !e.shiftKey && !isComposing) {
e.preventDefault();
askQuestion();
}
});
}
// Initialize Ask AI when DOM is loaded
document.addEventListener('DOMContentLoaded', function() {
setupAskAIEventListeners();
console.log('Ask AI initialized successfully');
});
// Global functions for onclick handlers
window.toggleAskAI = toggleAskAI;
window.askQuestion = askQuestion;

View File

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

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

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

Binary file not shown.

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Binary file not shown.

View File

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

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

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

After

Width:  |  Height:  |  Size: 4.8 KiB

View File

@@ -4,10 +4,17 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{{ config.title }}{% endblock %}</title>
<link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
{% include "oauth-assets.html" %}
<!-- Favicon -->
<link rel="icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<!-- Stylesheets -->
<link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="/css/svg-animation-package.css">
<link rel="stylesheet" href="/pkg/icomoon/style.css">
<link rel="stylesheet" href="/pkg/font-awesome/css/all.min.css">
{% block head %}{% endblock %}
</head>
<body>
@@ -15,31 +22,53 @@
<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">🤖</span>
Ask AI
<span class="ai-icon icon-ai"></span>
ai
</button>
</div>
</div>
</header>
<!-- Ask AI panel on all pages -->
<!-- Ask AI Panel -->
<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>
@@ -52,309 +81,17 @@
</div>
<footer class="main-footer">
<p>&copy; {{ config.author }}</p>
<div class="footer-social">
<a href="https://syu.is/syui" target="_blank"><i class="fab fa-bluesky"></i></a>
<a href="https://git.syui.ai/ai" target="_blank"><span class="icon-ai"></span></a>
<a href="https://git.syui.ai/syui" target="_blank"><span class="icon-git"></span></a>
</div>
<p>© {{ config.author }}</p>
</footer>
<script>
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>
<script src="/js/ask-ai.js"></script>
<script src="/js/theme.js"></script>
{% include "oauth-assets.html" %}
</body>
</html>
</html>

View File

@@ -20,19 +20,6 @@
<a href="{{ post.url }}">{{ post.title }}</a>
</h3>
{% if post.excerpt %}
<p class="post-excerpt">{{ post.excerpt }}</p>
{% endif %}
<div class="post-actions">
<a href="{{ post.url }}" class="read-more">Read more</a>
{% if post.markdown_url %}
<a href="{{ post.markdown_url }}" class="view-markdown" title="View Markdown">Markdown</a>
{% endif %}
{% if post.translation_url %}
<a href="{{ post.translation_url }}" class="view-translation" title="View Translation">🌐</a>
{% endif %}
</div>
</div>
</article>
{% endfor %}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,28 +1,21 @@
# Production environment variables
VITE_APP_HOST=https://log.syui.ai
VITE_OAUTH_CLIENT_ID=https://log.syui.ai/client-metadata.json
VITE_OAUTH_REDIRECT_URI=https://log.syui.ai/oauth/callback
VITE_ADMIN_DID=did:plc:uqzpqmrjnptsxezjx4xuh2mn
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
# 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
# Handle-based Configuration (DIDs resolved at runtime)
VITE_ATPROTO_PDS=syu.is
VITE_ADMIN_HANDLE=ai.syui.ai
VITE_AI_HANDLE=ai.syui.ai
VITE_OAUTH_COLLECTION=ai.syui.log
VITE_ATPROTO_WEB_URL=https://bsky.app
VITE_ATPROTO_HANDLE_LIST=["syui.syui.ai","ai.syui.ai","ai.ai"]
# AI Configuration
VITE_AI_ENABLED=true
VITE_AI_ASK_AI=true
VITE_AI_PROVIDER=ollama
VITE_AI_MODEL=gemma3:4b
VITE_AI_MODEL=gemma3:1b
VITE_AI_HOST=https://ollama.syui.ai
VITE_AI_SYSTEM_PROMPT="あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"
VITE_AI_DID=did:plc:4hqjfn7m6n5hno3doamuhgef
# API Configuration
VITE_BSKY_PUBLIC_API=https://public.api.bsky.app

View File

@@ -1,13 +1,15 @@
{
"name": "aicard",
"version": "0.1.0",
"version": "0.1.1",
"private": true,
"scripts": {
"dev": "vite --mode development",
"build": "vite build --mode production",
"build:dev": "vite build --mode development",
"build:local": "VITE_APP_HOST=http://localhost:4173 vite build --mode development",
"preview": "vite preview"
"preview": "npm run test:console && vite preview",
"test": "vitest",
"test:console": "node -r esbuild-register src/tests/console-test.ts"
},
"dependencies": {
"@atproto/api": "^0.15.12",
@@ -26,6 +28,9 @@
"@types/react-dom": "^18.2.18",
"@vitejs/plugin-react": "^4.2.1",
"typescript": "^5.3.3",
"vite": "^5.0.10"
"vite": "^5.0.10",
"vitest": "^1.1.0",
"esbuild": "^0.19.10",
"esbuild-register": "^3.5.0"
}
}

View File

@@ -1,13 +1,13 @@
{
"client_id": "https://log.syui.ai/client-metadata.json",
"client_name": "ai.card",
"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",
"client_id": "https://syui.ai/client-metadata.json",
"client_name": "ai.log",
"client_uri": "https://syui.ai",
"logo_uri": "https://syui.ai/favicon.ico",
"tos_uri": "https://syui.ai/terms",
"policy_uri": "https://syui.ai/privacy",
"redirect_uris": [
"https://log.syui.ai/oauth/callback",
"https://log.syui.ai/"
"https://syui.ai/oauth/callback",
"https://syui.ai/"
],
"response_types": [
"code"
@@ -21,4 +21,4 @@
"subject_type": "public",
"application_type": "web",
"dpop_bound_access_tokens": true
}
}

View File

@@ -162,11 +162,76 @@
}
.app-main {
max-width: 1200px;
max-width: 1000px;
margin: 0 auto;
padding: 40px 20px;
}
@media (max-width: 1000px) {
* {
max-width: 100% !important;
box-sizing: border-box !important;
}
.app .app-main {
max-width: 100% !important;
margin: 0 !important;
padding: 0px !important;
}
.comment-item {
padding: 0px !important;
margin: 0px !important;
}
.auth-section {
padding: 0px !important;
}
.comments-list {
padding: 0px !important;
}
.comment-section {
padding: 30px 0 !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;
overflow-x: hidden !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;
@@ -264,9 +329,18 @@
.comment-section {
max-width: 800px;
margin: 0 auto;
padding: 20px;
/* padding: 20px; - removed to avoid double padding */
}
@media (max-width: 768px) {
.comment-section {
max-width: 100%;
margin: 0;
padding: 0;
}
}
.auth-section {
background: #f8f9fa;
border: 1px solid #e9ecef;
@@ -276,6 +350,38 @@
text-align: center;
}
.auth-section.search-bar-layout {
display: flex;
align-items: center;
padding: 10px;
gap: 10px;
}
.auth-section.search-bar-layout .handle-input {
flex: 1;
margin: 0;
padding: 10px 15px;
font-size: 16px;
border: 1px solid #dee2e6;
border-radius: 6px 0 0 6px;
background: white;
outline: none;
transition: border-color 0.2s;
}
.auth-section.search-bar-layout .handle-input:focus {
border-color: var(--theme-color);
}
.auth-section.search-bar-layout .atproto-button {
margin: 0;
padding: 10px 20px;
border-radius: 0 6px 6px 0;
min-width: 50px;
font-weight: bold;
height: auto;
}
.atproto-button {
background: var(--theme-color);
color: var(--white);
@@ -309,6 +415,30 @@
text-align: center;
}
/* Override for search bar layout */
.search-bar-layout .handle-input {
width: auto;
text-align: left;
}
/* Mobile responsive for search bar */
@media (max-width: 480px) {
.auth-section.search-bar-layout {
flex-direction: column;
gap: 8px;
}
.auth-section.search-bar-layout .handle-input {
width: 100%;
border-radius: 6px;
}
.auth-section.search-bar-layout .atproto-button {
width: 100%;
border-radius: 6px;
}
}
.auth-hint {
color: #6c757d;
font-size: 14px;
@@ -441,9 +571,8 @@
}
.comments-list {
border: 1px solid #ddd;
border-radius: 8px;
padding: 20px;
padding: 0px;
}
.comments-header {
@@ -554,6 +683,8 @@
line-height: 1.5;
color: #333;
margin-bottom: 10px;
white-space: pre-wrap;
word-wrap: break-word;
}
.comment-meta {
@@ -800,28 +931,6 @@
background: #f6f8fa;
}
/* AI Chat History */
.ai-chat-list {
max-width: 100%;
border: 1px solid #ddd;
border-radius: 8px;
padding: 20px;
}
.chat-item {
border: 1px solid #d1d9e0;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
background: #ffffff;
}
.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.chat-actions {
display: flex;
@@ -872,4 +981,8 @@
padding: 40px 20px;
color: #656d76;
font-style: italic;
}
}
.chat-message.comment-style {
border-left: 4px solid var(--theme-color);
}

File diff suppressed because it is too large Load Diff

View File

@@ -14,7 +14,7 @@ const response = await fetch(`${aiConfig.host}/api/generate`, {
options: {
temperature: 0.9,
top_p: 0.9,
num_predict: 80,
num_predict: 200,
repeat_penalty: 1.1,
}
}),

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { User } from '../services/auth';
import { atprotoOAuthService } from '../services/atproto-oauth';
import { appConfig } from '../config/app';
import { appConfig, getCollectionNames } from '../config/app';
interface AIChatProps {
user: User | null;
@@ -14,26 +14,22 @@ export const AIChat: React.FC<AIChatProps> = ({ user, isEnabled }) => {
const [isProcessing, setIsProcessing] = useState(false);
const [aiProfile, setAiProfile] = useState<any>(null);
// Get AI settings from environment variables
// Get AI settings from appConfig (unified configuration)
const aiConfig = {
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',
enabled: appConfig.aiEnabled,
askAi: appConfig.aiAskAi,
provider: appConfig.aiProvider,
model: appConfig.aiModel,
host: appConfig.aiHost,
systemPrompt: appConfig.aiSystemPrompt,
aiDid: appConfig.aiDid,
bskyPublicApi: appConfig.bskyPublicApi,
};
// Fetch AI profile on load
useEffect(() => {
const fetchAIProfile = async () => {
console.log('=== AI PROFILE FETCH START ===');
console.log('AI DID:', aiConfig.aiDid);
if (!aiConfig.aiDid) {
console.log('No AI DID configured');
return;
}
@@ -41,9 +37,7 @@ 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,
@@ -51,21 +45,17 @@ 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,
@@ -73,21 +63,15 @@ 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();
@@ -100,9 +84,6 @@ 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);
@@ -114,7 +95,6 @@ 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'));
@@ -134,40 +114,50 @@ 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: appConfig.collections.chat,
question: question,
url: window.location.href,
createdAt: now.toISOString(),
$type: collections.chat,
post: {
url: currentUrl,
slug: postSlug,
title: postTitle,
date: new Date().toISOString(),
tags: [],
language: "ja"
},
type: "question",
text: question,
author: {
did: user.did,
handle: user.handle,
avatar: user.avatar,
displayName: user.displayName || user.handle,
},
context: {
page_title: document.title,
page_url: window.location.href,
},
createdAt: now.toISOString(),
};
await agent.api.com.atproto.repo.putRecord({
repo: user.did,
collection: appConfig.collections.chat,
collection: 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: appConfig.collections.chat,
collection: collections.chat,
limit: 10,
});
@@ -175,10 +165,10 @@ export const AIChat: React.FC<AIChatProps> = ({ user, isEnabled }) => {
if (chatRecords.data.records) {
chatHistoryText = chatRecords.data.records
.map((r: any) => {
if (r.value.question) {
return `User: ${r.value.question}`;
} else if (r.value.answer) {
return `AI: ${r.value.answer}`;
if (r.value.type === 'question') {
return `User: ${r.value.text}`;
} else if (r.value.type === 'answer') {
return `AI: ${r.value.text}`;
}
return '';
})
@@ -201,6 +191,7 @@ Answer:`;
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Origin': 'https://syui.ai',
},
body: JSON.stringify({
model: aiConfig.model,
@@ -209,7 +200,7 @@ Answer:`;
options: {
temperature: 0.9,
top_p: 0.9,
num_predict: 80, // Shorter responses for faster generation
num_predict: 200, // Longer responses for better answers
repeat_penalty: 1.1,
}
}),
@@ -235,37 +226,38 @@ 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: appConfig.collections.chat,
answer: aiAnswer,
question_rkey: rkey,
url: window.location.href,
createdAt: now.toISOString(),
$type: collections.chat,
post: {
url: currentUrl,
slug: postSlug,
title: postTitle,
date: new Date().toISOString(),
tags: [],
language: "ja"
},
type: "answer",
text: aiAnswer,
author: {
did: aiProfile.did,
handle: aiProfile.handle,
displayName: aiProfile.displayName,
avatar: aiProfile.avatar,
},
createdAt: now.toISOString(),
};
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: appConfig.collections.chat,
collection: collections.chat,
rkey: answerRkey,
record: answerRecord,
}).catch(err => {
console.error('Failed to save AI response to ATProto:', err);
// Silent fail for AI response saving
});
} catch (error) {
console.error('Failed to generate AI response:', error);
window.dispatchEvent(new CustomEvent('aiResponseError', {
detail: { error: 'AI応答の生成に失敗しました' }
}));

View File

@@ -32,7 +32,7 @@ export const AIProfile: React.FC<AIProfileProps> = ({ aiDid }) => {
description: response.data.description,
});
} catch (error) {
console.error('Failed to fetch AI profile:', error);
// Failed to fetch AI profile
// Fallback to basic info
setProfile({
did: aiDid,

View File

@@ -26,7 +26,7 @@ export const CardBox: React.FC<CardBoxProps> = ({ userDid }) => {
const data = await atprotoOAuthService.getCardsFromBox();
setBoxData(data);
} catch (err) {
console.error('カードボックス読み込みエラー:', err);
// Failed to load card box
setError(err instanceof Error ? err.message : 'カードボックスの読み込みに失敗しました');
} finally {
setLoading(false);
@@ -52,7 +52,7 @@ export const CardBox: React.FC<CardBoxProps> = ({ userDid }) => {
setBoxData({ records: [] });
alert('カードボックスを削除しました');
} catch (err) {
console.error('カードボックス削除エラー:', err);
// Failed to delete card box
setError(err instanceof Error ? err.message : 'カードボックスの削除に失敗しました');
} finally {
setIsDeleting(false);

View File

@@ -32,7 +32,7 @@ export const CardList: React.FC = () => {
const data = await response.json();
setMasterData(data);
} catch (err) {
console.error('Error loading card master data:', err);
// Failed to load card master data
setError(err instanceof Error ? err.message : 'Failed to load card data');
} finally {
setLoading(false);

View File

@@ -29,7 +29,7 @@ export const CollectionAnalysis: React.FC<CollectionAnalysisProps> = ({ userDid
const result = await aiCardApi.analyzeCollection(userDid);
setAnalysis(result);
} catch (err) {
console.error('Collection analysis failed:', err);
// Collection analysis failed
setError('AI分析機能を利用するにはai.gptサーバーが必要です。基本機能はai.cardサーバーのみで利用できます。');
} finally {
setLoading(false);

View File

@@ -48,7 +48,7 @@ export const GachaAnimation: React.FC<GachaAnimationProps> = ({
await atprotoOAuthService.saveCardToCollection(card);
alert('カードデータをatprotoコレクションに保存しました');
} catch (error) {
console.error('保存エラー:', error);
// Failed to save card
alert('保存に失敗しました。認証が必要かもしれません。');
} finally {
setIsSharing(false);

View File

@@ -30,7 +30,7 @@ export const GachaStats: React.FC = () => {
try {
result = await aiCardApi.getEnhancedStats();
} catch (aiError) {
console.warn('AI統計が利用できません、基本統計に切り替えます:', aiError);
// AI stats unavailable, using basic stats
setUseAI(false);
result = await cardApi.getGachaStats();
}
@@ -39,7 +39,7 @@ export const GachaStats: React.FC = () => {
}
setStats(result);
} catch (err) {
console.error('Gacha stats failed:', err);
// Gacha stats failed
setError('統計データの取得に失敗しました。ai.cardサーバーが起動していることを確認してください。');
} finally {
setLoading(false);

View File

@@ -160,7 +160,7 @@ export const Login: React.FC<LoginProps> = ({ onLogin, onClose, defaultHandle })
/>
<small>
<a href="https://bsky.app/settings/app-passwords" target="_blank" rel="noopener noreferrer">
<a href={`${import.meta.env.VITE_ATPROTO_WEB_URL || 'https://bsky.app'}/settings/app-passwords`} target="_blank" rel="noopener noreferrer">
</a>
使

View File

@@ -7,8 +7,6 @@ interface OAuthCallbackProps {
}
export const OAuthCallback: React.FC<OAuthCallbackProps> = ({ onSuccess, onError }) => {
console.log('=== OAUTH CALLBACK COMPONENT MOUNTED ===');
console.log('Current URL:', window.location.href);
const [isProcessing, setIsProcessing] = useState(true);
const [needsHandle, setNeedsHandle] = useState(false);
@@ -18,12 +16,10 @@ export const OAuthCallback: React.FC<OAuthCallbackProps> = ({ onSuccess, onError
useEffect(() => {
// Add timeout to prevent infinite loading
const timeoutId = setTimeout(() => {
console.error('OAuth callback timeout');
onError('OAuth認証がタイムアウトしました');
}, 10000); // 10 second timeout
const handleCallback = async () => {
console.log('=== HANDLE CALLBACK STARTED ===');
try {
// Handle both query params (?) and hash params (#)
const hashParams = new URLSearchParams(window.location.hash.substring(1));
@@ -35,14 +31,6 @@ export const OAuthCallback: React.FC<OAuthCallbackProps> = ({ onSuccess, onError
const error = hashParams.get('error') || queryParams.get('error');
const iss = hashParams.get('iss') || queryParams.get('iss');
console.log('OAuth callback parameters:', {
code: code ? code.substring(0, 20) + '...' : null,
state: state,
error: error,
iss: iss,
hash: window.location.hash,
search: window.location.search
});
if (error) {
throw new Error(`OAuth error: ${error}`);
@@ -52,12 +40,10 @@ export const OAuthCallback: React.FC<OAuthCallbackProps> = ({ onSuccess, onError
throw new Error('Missing OAuth parameters');
}
console.log('Processing OAuth callback with params:', { code: code?.substring(0, 10) + '...', state, iss });
// Use the official BrowserOAuthClient to handle the callback
const result = await atprotoOAuthService.handleOAuthCallback();
if (result) {
console.log('OAuth callback completed successfully:', result);
// Success - notify parent component
onSuccess(result.did, result.handle);
@@ -66,11 +52,7 @@ export const OAuthCallback: React.FC<OAuthCallbackProps> = ({ onSuccess, onError
}
} catch (error) {
console.error('OAuth callback error:', error);
// Even if OAuth fails, try to continue with a fallback approach
console.warn('OAuth callback failed, attempting fallback...');
try {
// Create a minimal session to allow the user to proceed
const fallbackSession = {
@@ -82,7 +64,6 @@ export const OAuthCallback: React.FC<OAuthCallbackProps> = ({ onSuccess, onError
onSuccess(fallbackSession.did, fallbackSession.handle);
} catch (fallbackError) {
console.error('Fallback also failed:', fallbackError);
onError(error instanceof Error ? error.message : 'OAuth認証に失敗しました');
}
} finally {
@@ -104,17 +85,13 @@ export const OAuthCallback: React.FC<OAuthCallbackProps> = ({ onSuccess, onError
const trimmedHandle = handle.trim();
if (!trimmedHandle) {
console.log('Handle is empty');
return;
}
console.log('Submitting handle:', trimmedHandle);
setIsProcessing(true);
try {
// Resolve DID from handle
const did = await atprotoOAuthService.resolveDIDFromHandle(trimmedHandle);
console.log('Resolved DID:', did);
// Update session with resolved DID and handle
const updatedSession = {
@@ -129,7 +106,6 @@ export const OAuthCallback: React.FC<OAuthCallbackProps> = ({ onSuccess, onError
// Success - notify parent component
onSuccess(did, trimmedHandle);
} catch (error) {
console.error('Failed to resolve DID:', error);
setIsProcessing(false);
onError(error instanceof Error ? error.message : 'ハンドルからDIDの解決に失敗しました');
}
@@ -149,7 +125,6 @@ export const OAuthCallback: React.FC<OAuthCallbackProps> = ({ onSuccess, onError
type="text"
value={handle}
onChange={(e) => {
console.log('Input changed:', e.target.value);
setHandle(e.target.value);
}}
placeholder="例: syui.ai または user.bsky.social"

View File

@@ -6,14 +6,9 @@ export const OAuthCallbackPage: React.FC = () => {
const navigate = useNavigate();
useEffect(() => {
console.log('=== OAUTH CALLBACK PAGE MOUNTED ===');
console.log('Current URL:', window.location.href);
console.log('Search params:', window.location.search);
console.log('Pathname:', window.location.pathname);
}, []);
const handleSuccess = (did: string, handle: string) => {
console.log('OAuth success, redirecting to home:', { did, handle });
// Add a small delay to ensure state is properly updated
setTimeout(() => {
@@ -22,7 +17,6 @@ export const OAuthCallbackPage: React.FC = () => {
};
const handleError = (error: string) => {
console.error('OAuth error, redirecting to home:', error);
// Add a small delay before redirect
setTimeout(() => {

View File

@@ -1,10 +1,14 @@
// Application configuration
export interface AppConfig {
adminDid: string;
adminHandle: string;
aiDid: string;
aiHandle: string;
aiDisplayName: string;
aiAvatar: string;
aiDescription: string;
collections: {
comment: string;
user: string;
chat: string;
base: string; // Base collection like "ai.syui.log"
};
host: string;
rkey?: string; // Current post rkey if on post page
@@ -13,13 +17,36 @@ export interface AppConfig {
aiProvider: string;
aiModel: string;
aiHost: string;
aiSystemPrompt: string;
allowedHandles: string[]; // Handles allowed for OAuth authentication
atprotoPds: string; // Configured PDS for admin/ai handles
// Legacy - prefer per-user PDS detection
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 generateCollectionNames(host: string): { comment: string; user: string; chat: string } {
function generateBaseCollectionFromHost(host: string): string {
try {
// Remove protocol if present
const cleanHost = host.replace(/^https?:\/\//, '');
@@ -34,43 +61,50 @@ function generateCollectionNames(host: string): { comment: string; user: string;
// Reverse the parts for collection naming
// log.syui.ai -> ai.syui.log
const reversedParts = parts.reverse();
const collectionBase = reversedParts.join('.');
return {
comment: collectionBase,
user: `${collectionBase}.user`,
chat: `${collectionBase}.chat`
};
const result = reversedParts.join('.');
return result;
} catch (error) {
console.warn('Failed to generate collection names from host:', host, error);
// Fallback to default collections
return {
comment: 'ai.syui.log',
user: 'ai.syui.log.user',
chat: 'ai.syui.log.chat'
};
// Fallback to default
return 'ai.syui.log';
}
}
// Extract rkey from current URL
// /posts/xxx.html -> xxx
// /posts/xxx -> xxx (remove .html if present)
function extractRkeyFromUrl(): string | undefined {
const pathname = window.location.pathname;
const match = pathname.match(/\/posts\/([^/]+)\.html$/);
return match ? match[1] : undefined;
const match = pathname.match(/\/posts\/([^/]+)\/?$/);
if (match) {
// Remove .html extension if present
return match[1].replace(/\.html$/, '');
}
return undefined;
}
// Get application configuration from environment variables
export function getAppConfig(): AppConfig {
const host = import.meta.env.VITE_APP_HOST || 'https://log.syui.ai';
const adminHandle = import.meta.env.VITE_ADMIN_HANDLE || 'ai.syui.ai';
const aiHandle = import.meta.env.VITE_AI_HANDLE || 'ai.syui.ai';
// DIDsはハンドルから実行時に解決されるフォールバック用のみ保持
const adminDid = import.meta.env.VITE_ADMIN_DID || 'did:plc:uqzpqmrjnptsxezjx4xuh2mn';
const aiDid = import.meta.env.VITE_AI_DID || 'did:plc:6qyecktefllvenje24fcxnie';
const aiDisplayName = import.meta.env.VITE_AI_DISPLAY_NAME || 'ai';
const aiAvatar = import.meta.env.VITE_AI_AVATAR || '';
const aiDescription = import.meta.env.VITE_AI_DESCRIPTION || '';
// Priority: Environment variables > Auto-generated from host
const autoGeneratedCollections = generateCollectionNames(host);
const autoGeneratedBase = generateBaseCollectionFromHost(host);
let baseCollection = import.meta.env.VITE_OAUTH_COLLECTION || autoGeneratedBase;
// Ensure base collection is never undefined
if (!baseCollection) {
baseCollection = 'ai.syui.log';
}
const collections = {
comment: import.meta.env.VITE_COLLECTION_COMMENT || autoGeneratedCollections.comment,
user: import.meta.env.VITE_COLLECTION_USER || autoGeneratedCollections.user,
chat: import.meta.env.VITE_COLLECTION_CHAT || autoGeneratedCollections.chat,
base: baseCollection,
};
const rkey = extractRkeyFromUrl();
@@ -79,21 +113,31 @@ export function getAppConfig(): AppConfig {
const aiEnabled = import.meta.env.VITE_AI_ENABLED === 'true';
const aiAskAi = import.meta.env.VITE_AI_ASK_AI === 'true';
const aiProvider = import.meta.env.VITE_AI_PROVIDER || 'ollama';
const aiModel = import.meta.env.VITE_AI_MODEL || 'gemma2:2b';
const aiModel = import.meta.env.VITE_AI_MODEL || 'gemma3:4b';
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 atprotoPds = import.meta.env.VITE_ATPROTO_PDS || 'syu.is';
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
});
// Parse allowed handles list
const allowedHandlesStr = import.meta.env.VITE_ATPROTO_HANDLE_LIST || '[]';
let allowedHandles: string[] = [];
try {
allowedHandles = JSON.parse(allowedHandlesStr);
} catch {
// If parsing fails, allow all handles (empty array means no restriction)
allowedHandles = [];
}
return {
adminDid,
adminHandle,
aiDid,
aiHandle,
aiDisplayName,
aiAvatar,
aiDescription,
collections,
host,
rkey,
@@ -102,7 +146,11 @@ export function getAppConfig(): AppConfig {
aiProvider,
aiModel,
aiHost,
bskyPublicApi
aiSystemPrompt,
allowedHandles,
atprotoPds,
bskyPublicApi,
atprotoApi
};
}

View File

@@ -12,10 +12,8 @@ import { OAuthEndpointHandler } from './utils/oauth-endpoints'
// Mount React app to all comment-atproto divs
const mountPoints = document.querySelectorAll('#comment-atproto');
console.log(`Found ${mountPoints.length} comment-atproto mount points`);
mountPoints.forEach((mountPoint, index) => {
console.log(`Mounting React app to comment-atproto #${index + 1}`);
ReactDOM.createRoot(mountPoint as HTMLElement).render(
<React.StrictMode>
<BrowserRouter>

View File

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

View File

@@ -12,6 +12,7 @@ interface AtprotoSession {
class AtprotoOAuthService {
private oauthClient: BrowserOAuthClient | null = null;
private oauthClientSyuIs: BrowserOAuthClient | null = null;
private agent: Agent | null = null;
private initializePromise: Promise<void> | null = null;
@@ -31,51 +32,50 @@ 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
// Initialize both OAuth clients
this.oauthClient = await BrowserOAuthClient.load({
clientId: clientId,
handleResolver: 'https://bsky.social', // Default resolver
handleResolver: 'https://bsky.social',
plcDirectoryUrl: 'https://plc.directory',
});
this.oauthClientSyuIs = await BrowserOAuthClient.load({
clientId: clientId,
handleResolver: 'https://syu.is',
plcDirectoryUrl: 'https://plc.syu.is',
});
console.log('BrowserOAuthClient initialized successfully with multi-PDS support');
// Try to restore existing session
const result = await this.oauthClient.init();
// Try to restore existing session from either client
let result = await this.oauthClient.init();
if (!result?.session) {
result = await this.oauthClientSyuIs.init();
}
if (result?.session) {
console.log('Existing session restored:', {
did: result.session.did,
handle: result.session.handle || 'unknown',
hasAccessJwt: !!result.session.accessJwt,
hasRefreshJwt: !!result.session.refreshJwt
});
// Create Agent instance with proper configuration
console.log('Creating Agent with session:', result.session);
// Delete the old agent initialization code - we'll create it properly below
// Set the session after creating the agent
// The session object from BrowserOAuthClient appears to be a special object
console.log('Full session object:', result.session);
console.log('Session type:', typeof result.session);
console.log('Session constructor:', result.session?.constructor?.name);
// Try to iterate over the session object
if (result.session) {
console.log('Session properties:');
for (const key in result.session) {
console.log(` ${key}:`, result.session[key]);
}
// Check if session has methods
const methods = Object.getOwnPropertyNames(Object.getPrototypeOf(result.session));
console.log('Session methods:', methods);
}
// BrowserOAuthClient might return a Session object that needs to be used with the agent
@@ -83,56 +83,28 @@ 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;
let handle = session.handle || 'unknown';
// Create Agent directly with session (per official docs)
try {
this.agent = new Agent(session);
console.log('Agent created directly with session');
// Check if agent has session info after creation
console.log('Agent after creation:');
console.log('- agent.did:', this.agent.did);
console.log('- agent.session:', this.agent.session);
if (this.agent.session) {
console.log('- agent.session.did:', this.agent.session.did);
console.log('- agent.session.handle:', this.agent.session.handle);
}
} catch (err) {
console.log('Failed to create Agent with session directly, trying dpopFetch method');
// Fallback to dpopFetch method
this.agent = new Agent({
service: session.server?.serviceEndpoint || 'https://bsky.social',
@@ -145,7 +117,7 @@ class AtprotoOAuthService {
// If handle is missing, try multiple methods to resolve it
if (!handle || handle === 'unknown') {
console.log('Handle not in session, attempting to resolve...');
// Method 1: Try using the agent to get profile
try {
@@ -154,11 +126,11 @@ class AtprotoOAuthService {
if (profile.data.handle) {
handle = profile.data.handle;
(this as any)._sessionInfo.handle = handle;
console.log('Successfully resolved handle via getProfile:', handle);
return { did, handle };
}
} catch (err) {
console.error('getProfile failed:', err);
}
// Method 2: Try using describeRepo
@@ -169,18 +141,20 @@ class AtprotoOAuthService {
if (repoDesc.data.handle) {
handle = repoDesc.data.handle;
(this as any)._sessionInfo.handle = handle;
console.log('Got handle from describeRepo:', handle);
return { did, handle };
}
} catch (err) {
console.error('describeRepo failed:', err);
}
// Method 3: Hardcoded fallback for known DIDs
if (did === 'did:plc:uqzpqmrjnptsxezjx4xuh2mn') {
handle = 'syui.ai';
// Method 3: Fallback for admin DID
const adminDid = import.meta.env.VITE_ADMIN_DID;
if (did === adminDid) {
const appHost = import.meta.env.VITE_APP_HOST || 'https://syui.ai';
handle = new URL(appHost).hostname;
(this as any)._sessionInfo.handle = handle;
console.log('Using hardcoded handle for known DID');
}
}
@@ -191,7 +165,7 @@ class AtprotoOAuthService {
// Use environment variable if available
const envClientId = import.meta.env.VITE_OAUTH_CLIENT_ID;
if (envClientId) {
console.log('Using client ID from environment:', envClientId);
return envClientId;
}
@@ -200,7 +174,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
}
@@ -208,39 +182,15 @@ class AtprotoOAuthService {
return `${origin}/client-metadata.json`;
}
private detectPDSFromHandle(handle: string): string {
console.log('Detecting PDS for handle:', handle);
// Supported PDS hosts and their corresponding handles
const pdsMapping = {
'syu.is': 'https://syu.is',
'bsky.social': 'https://bsky.social',
};
// 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...');
if (!this.oauthClient || !this.oauthClientSyuIs) {
await this.initialize();
}
if (!this.oauthClient) {
throw new Error('Failed to initialize OAuth client');
if (!this.oauthClient || !this.oauthClientSyuIs) {
throw new Error('Failed to initialize OAuth clients');
}
// If handle is not provided, prompt user
@@ -251,75 +201,42 @@ 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,
});
}
// Start OAuth authorization flow
console.log('Calling oauthClient.authorize with handle:', handle);
// Determine which OAuth client to use
const allowedHandlesStr = import.meta.env.VITE_ATPROTO_HANDLE_LIST || '[]';
let allowedHandles: string[] = [];
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({
timestamp: new Date().toISOString(),
handle: handle,
authUrl: authUrl.toString(),
currentUrl: window.location.href
}));
// 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;
allowedHandles = JSON.parse(allowedHandlesStr);
} catch {
allowedHandles = [];
}
const usesSyuIs = handle.endsWith('.syu.is') || allowedHandles.includes(handle);
const oauthClient = usesSyuIs ? this.oauthClientSyuIs : this.oauthClient;
// Start OAuth authorization flow
const authUrl = await oauthClient.authorize(handle, {
scope: 'atproto transition:generic',
});
// Redirect to authorization server
window.location.href = authUrl.toString();
} catch (error) {
console.error('Failed to initiate OAuth flow:', error);
throw new Error(`OAuth認証の開始に失敗しました: ${error}`);
}
}
async handleOAuthCallback(): Promise<{ did: string; handle: string } | null> {
try {
console.log('=== HANDLING OAUTH CALLBACK ===');
console.log('Current URL:', window.location.href);
console.log('URL hash:', window.location.hash);
console.log('URL search:', window.location.search);
// BrowserOAuthClient should automatically handle the callback
// We just need to initialize it and it will process the current URL
if (!this.oauthClient) {
console.log('OAuth client not initialized, initializing now...');
await this.initialize();
}
@@ -327,11 +244,11 @@ class AtprotoOAuthService {
throw new Error('Failed to initialize OAuth client');
}
console.log('OAuth client ready, initializing to process callback...');
// Call init() again to process the callback URL
const result = await this.oauthClient.init();
console.log('OAuth callback processing result:', result);
if (result?.session) {
// Process the session
@@ -339,47 +256,36 @@ class AtprotoOAuthService {
}
// If no session yet, wait a bit and try again
console.log('No session found immediately, waiting...');
await new Promise(resolve => setTimeout(resolve, 1000));
// Try to check session again
const sessionCheck = await this.checkSession();
if (sessionCheck) {
console.log('Session found after delay:', sessionCheck);
return sessionCheck;
}
console.warn('OAuth callback completed but no session was created');
return null;
} catch (error) {
console.error('OAuth callback handling failed:', error);
console.error('Error details:', {
name: error.name,
message: error.message,
stack: error.stack
});
throw new Error(`OAuth認証の完了に失敗しました: ${error.message}`);
}
}
async checkSession(): Promise<{ did: string; handle: string } | null> {
try {
console.log('=== CHECK SESSION CALLED ===');
if (!this.oauthClient) {
console.log('No OAuth client, initializing...');
await this.initialize();
}
if (!this.oauthClient) {
console.log('OAuth client initialization failed');
return null;
}
console.log('Running oauthClient.init() to check session...');
const result = await this.oauthClient.init();
console.log('oauthClient.init() result:', result);
if (result?.session) {
// Use the common session processing method
@@ -388,7 +294,7 @@ class AtprotoOAuthService {
return null;
} catch (error) {
console.error('Session check failed:', error);
return null;
}
}
@@ -398,13 +304,7 @@ class AtprotoOAuthService {
}
getSession(): AtprotoSession | null {
console.log('getSession called');
console.log('Current state:', {
hasAgent: !!this.agent,
hasAgentSession: !!this.agent?.session,
hasOAuthClient: !!this.oauthClient,
hasSessionInfo: !!(this as any)._sessionInfo
});
// First check if we have an agent with session
if (this.agent?.session) {
@@ -414,7 +314,7 @@ class AtprotoOAuthService {
accessJwt: this.agent.session.accessJwt || '',
refreshJwt: this.agent.session.refreshJwt || '',
};
console.log('Returning agent session:', session);
return session;
}
@@ -426,11 +326,11 @@ class AtprotoOAuthService {
accessJwt: 'dpop-protected', // Indicate that tokens are handled by dpopFetch
refreshJwt: 'dpop-protected',
};
console.log('Returning stored session info:', session);
return session;
}
console.log('No session available');
return null;
}
@@ -450,28 +350,20 @@ 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);
// Ignore logout errors
}
// Reset the OAuth client to force re-initialization
@@ -483,20 +375,18 @@ class AtprotoOAuthService {
localStorage.removeItem('atproto_session');
sessionStorage.clear();
// Clear all localStorage items that might be related to OAuth
const keysToRemove: string[] = [];
// Clear all OAuth-related storage
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && (key.includes('oauth') || key.includes('atproto') || key.includes('session'))) {
keysToRemove.push(key);
localStorage.removeItem(key);
}
}
keysToRemove.forEach(key => {
console.log('Removing localStorage key:', key);
localStorage.removeItem(key);
});
console.log('=== LOGOUT COMPLETED ===');
// Clear internal session info
(this as any)._sessionInfo = null;
// Force page reload to ensure clean state
setTimeout(() => {
@@ -504,7 +394,7 @@ class AtprotoOAuthService {
}, 100);
} catch (error) {
console.error('Logout failed:', error);
}
}
@@ -519,8 +409,8 @@ class AtprotoOAuthService {
const did = sessionInfo.did;
try {
console.log('Saving cards to atproto collection...');
console.log('Using DID:', did);
// Ensure we have a fresh agent
if (!this.agent) {
@@ -550,13 +440,6 @@ class AtprotoOAuthService {
createdAt: createdAt
};
console.log('PutRecord request:', {
repo: did,
collection: collection,
rkey: rkey,
record: record
});
// Use Agent's com.atproto.repo.putRecord method
const response = await this.agent.com.atproto.repo.putRecord({
@@ -566,9 +449,9 @@ class AtprotoOAuthService {
record: record
});
console.log('カードデータをai.card.boxに保存しました:', response);
} catch (error) {
console.error('カードボックス保存エラー:', error);
throw error;
}
}
@@ -584,8 +467,8 @@ class AtprotoOAuthService {
const did = sessionInfo.did;
try {
console.log('Fetching cards from atproto collection...');
console.log('Using DID:', did);
// Ensure we have a fresh agent
if (!this.agent) {
@@ -598,7 +481,7 @@ class AtprotoOAuthService {
rkey: 'self'
});
console.log('Cards from box response:', response);
// Convert to expected format
const result = {
@@ -611,7 +494,7 @@ class AtprotoOAuthService {
return result;
} catch (error) {
console.error('カードボックス取得エラー:', error);
// If record doesn't exist, return empty
if (error.toString().includes('RecordNotFound')) {
@@ -633,8 +516,8 @@ class AtprotoOAuthService {
const did = sessionInfo.did;
try {
console.log('Deleting card box collection...');
console.log('Using DID:', did);
// Ensure we have a fresh agent
if (!this.agent) {
@@ -647,33 +530,35 @@ class AtprotoOAuthService {
rkey: 'self'
});
console.log('Card box deleted successfully:', response);
} catch (error) {
console.error('カードボックス削除エラー:', error);
throw error;
}
}
// 手動でトークンを設定(開発・デバッグ用)
setManualTokens(accessJwt: string, refreshJwt: string): void {
console.warn('Manual token setting is not supported with official BrowserOAuthClient');
console.warn('Please use the proper OAuth flow instead');
// For backward compatibility, store in localStorage
const adminDid = import.meta.env.VITE_ADMIN_DID || 'did:plc:unknown';
const appHost = import.meta.env.VITE_APP_HOST || 'https://example.com';
const session: AtprotoSession = {
did: 'did:plc:uqzpqmrjnptsxezjx4xuh2mn',
handle: 'syui.ai',
did: adminDid,
handle: new URL(appHost).hostname,
accessJwt: accessJwt,
refreshJwt: refreshJwt
};
localStorage.setItem('atproto_session', JSON.stringify(session));
console.log('Manual tokens stored in localStorage for backward compatibility');
}
// 後方互換性のための従来関数
saveSessionToStorage(session: AtprotoSession): void {
console.warn('saveSessionToStorage is deprecated with BrowserOAuthClient');
localStorage.setItem('atproto_session', JSON.stringify(session));
}

View File

@@ -0,0 +1,135 @@
// Simple console test for OAuth app
// This runs before 'npm run preview' to display test results
// Mock import.meta.env for Node.js environment
(global as any).import = {
meta: {
env: {
VITE_ATPROTO_PDS: process.env.VITE_ATPROTO_PDS || 'syu.is',
VITE_ADMIN_HANDLE: process.env.VITE_ADMIN_HANDLE || 'ai.syui.ai',
VITE_AI_HANDLE: process.env.VITE_AI_HANDLE || 'ai.syui.ai',
VITE_OAUTH_COLLECTION: process.env.VITE_OAUTH_COLLECTION || 'ai.syui.log',
VITE_ATPROTO_HANDLE_LIST: process.env.VITE_ATPROTO_HANDLE_LIST || '["syui.ai", "ai.syui.ai", "yui.syui.ai"]',
VITE_APP_HOST: process.env.VITE_APP_HOST || 'https://log.syui.ai'
}
}
};
// Simple implementation of functions for testing
function detectPdsFromHandle(handle: string): string {
if (handle.endsWith('.syu.is') || handle.endsWith('.syui.ai')) {
return 'syu.is';
}
if (handle.endsWith('.bsky.social')) {
return 'bsky.social';
}
// Default case - check if it's in the allowed list
const allowedHandles = JSON.parse((global as any).import.meta.env.VITE_ATPROTO_HANDLE_LIST || '[]');
if (allowedHandles.includes(handle)) {
return (global as any).import.meta.env.VITE_ATPROTO_PDS || 'syu.is';
}
return 'bsky.social';
}
function getNetworkConfig(pds: string) {
switch (pds) {
case 'bsky.social':
case 'bsky.app':
return {
pdsApi: `https://${pds}`,
plcApi: 'https://plc.directory',
bskyApi: 'https://public.api.bsky.app',
webUrl: 'https://bsky.app'
};
case 'syu.is':
return {
pdsApi: 'https://syu.is',
plcApi: 'https://plc.syu.is',
bskyApi: 'https://bsky.syu.is',
webUrl: 'https://web.syu.is'
};
default:
return {
pdsApi: `https://${pds}`,
plcApi: 'https://plc.directory',
bskyApi: 'https://public.api.bsky.app',
webUrl: 'https://bsky.app'
};
}
}
// Main test execution
console.log('\n=== OAuth App Configuration Tests ===\n');
// Test 1: Handle input behavior
console.log('1. Handle Input → PDS Detection:');
const testHandles = [
'syui.ai',
'syui.syu.is',
'syui.syui.ai',
'test.bsky.social',
'unknown.handle'
];
testHandles.forEach(handle => {
const pds = detectPdsFromHandle(handle);
const config = getNetworkConfig(pds);
console.log(` ${handle.padEnd(20)} → PDS: ${pds.padEnd(12)} → API: ${config.pdsApi}`);
});
// Test 2: Environment variable impact
console.log('\n2. Current Environment Configuration:');
const env = (global as any).import.meta.env;
console.log(` VITE_ATPROTO_PDS: ${env.VITE_ATPROTO_PDS}`);
console.log(` VITE_ADMIN_HANDLE: ${env.VITE_ADMIN_HANDLE}`);
console.log(` VITE_AI_HANDLE: ${env.VITE_AI_HANDLE}`);
console.log(` VITE_OAUTH_COLLECTION: ${env.VITE_OAUTH_COLLECTION}`);
console.log(` VITE_ATPROTO_HANDLE_LIST: ${env.VITE_ATPROTO_HANDLE_LIST}`);
// Test 3: API endpoint generation
console.log('\n3. Generated API Endpoints:');
const adminPds = detectPdsFromHandle(env.VITE_ADMIN_HANDLE);
const adminConfig = getNetworkConfig(adminPds);
console.log(` Admin PDS detection: ${env.VITE_ADMIN_HANDLE}${adminPds}`);
console.log(` Admin API endpoints:`);
console.log(` - PDS API: ${adminConfig.pdsApi}`);
console.log(` - Bsky API: ${adminConfig.bskyApi}`);
console.log(` - Web URL: ${adminConfig.webUrl}`);
// Test 4: Collection URLs
console.log('\n4. Collection API URLs:');
const baseCollection = env.VITE_OAUTH_COLLECTION;
console.log(` User list: ${adminConfig.pdsApi}/xrpc/com.atproto.repo.listRecords?repo=${env.VITE_ADMIN_HANDLE}&collection=${baseCollection}.user`);
console.log(` Chat: ${adminConfig.pdsApi}/xrpc/com.atproto.repo.listRecords?repo=${env.VITE_ADMIN_HANDLE}&collection=${baseCollection}.chat`);
console.log(` Lang: ${adminConfig.pdsApi}/xrpc/com.atproto.repo.listRecords?repo=${env.VITE_ADMIN_HANDLE}&collection=${baseCollection}.chat.lang`);
console.log(` Comment: ${adminConfig.pdsApi}/xrpc/com.atproto.repo.listRecords?repo=${env.VITE_ADMIN_HANDLE}&collection=${baseCollection}.chat.comment`);
// Test 5: OAuth routing logic
console.log('\n5. OAuth Authorization Logic:');
const allowedHandles = JSON.parse(env.VITE_ATPROTO_HANDLE_LIST || '[]');
console.log(` Allowed handles: ${JSON.stringify(allowedHandles)}`);
console.log(` OAuth scenarios:`);
const oauthTestCases = [
'syui.ai', // Should use syu.is (in allowed list)
'test.syu.is', // Should use syu.is (*.syu.is pattern)
'user.bsky.social' // Should use bsky.social (default)
];
oauthTestCases.forEach(handle => {
const pds = detectPdsFromHandle(handle);
const isAllowed = allowedHandles.includes(handle);
const reason = handle.endsWith('.syu.is') ? '*.syu.is pattern' :
isAllowed ? 'in allowed list' :
'default';
console.log(` ${handle.padEnd(20)} → https://${pds}/oauth/authorize (${reason})`);
});
// Test 6: AI Profile Resolution
console.log('\n6. AI Profile Resolution:');
const aiPds = detectPdsFromHandle(env.VITE_AI_HANDLE);
const aiConfig = getNetworkConfig(aiPds);
console.log(` AI Handle: ${env.VITE_AI_HANDLE} → PDS: ${aiPds}`);
console.log(` AI Profile API: ${aiConfig.bskyApi}/xrpc/app.bsky.actor.getProfile?actor=${env.VITE_AI_HANDLE}`);
console.log('\n=== Tests Complete ===\n');

View File

@@ -0,0 +1,141 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { getAppConfig } from '../config/app';
import { detectPdsFromHandle, getNetworkConfig } from '../App';
// Test helper to mock environment variables
const mockEnv = (vars: Record<string, string>) => {
Object.keys(vars).forEach(key => {
(import.meta.env as any)[key] = vars[key];
});
};
describe('OAuth App Tests', () => {
describe('Handle Input Behavior', () => {
it('should detect PDS for syui.ai (Bluesky)', () => {
const pds = detectPdsFromHandle('syui.ai');
expect(pds).toBe('bsky.social');
});
it('should detect PDS for syui.syu.is (syu.is)', () => {
const pds = detectPdsFromHandle('syui.syu.is');
expect(pds).toBe('syu.is');
});
it('should detect PDS for syui.syui.ai (syu.is)', () => {
const pds = detectPdsFromHandle('syui.syui.ai');
expect(pds).toBe('syu.is');
});
it('should use network config for different PDS', () => {
const bskyConfig = getNetworkConfig('bsky.social');
expect(bskyConfig.pdsApi).toBe('https://bsky.social');
expect(bskyConfig.bskyApi).toBe('https://public.api.bsky.app');
expect(bskyConfig.webUrl).toBe('https://bsky.app');
const syuisConfig = getNetworkConfig('syu.is');
expect(syuisConfig.pdsApi).toBe('https://syu.is');
expect(syuisConfig.bskyApi).toBe('https://bsky.syu.is');
expect(syuisConfig.webUrl).toBe('https://web.syu.is');
});
});
describe('Environment Variable Changes', () => {
beforeEach(() => {
// Reset environment variables
delete (import.meta.env as any).VITE_ATPROTO_PDS;
delete (import.meta.env as any).VITE_ADMIN_HANDLE;
delete (import.meta.env as any).VITE_AI_HANDLE;
});
it('should use correct PDS for AI profile', () => {
mockEnv({
VITE_ATPROTO_PDS: 'syu.is',
VITE_ADMIN_HANDLE: 'ai.syui.ai',
VITE_AI_HANDLE: 'ai.syui.ai'
});
const config = getAppConfig();
expect(config.atprotoPds).toBe('syu.is');
expect(config.adminHandle).toBe('ai.syui.ai');
expect(config.aiHandle).toBe('ai.syui.ai');
// Network config should use syu.is endpoints
const networkConfig = getNetworkConfig(config.atprotoPds);
expect(networkConfig.bskyApi).toBe('https://bsky.syu.is');
});
it('should construct correct API requests for admin userlist', () => {
mockEnv({
VITE_ATPROTO_PDS: 'syu.is',
VITE_ADMIN_HANDLE: 'ai.syui.ai',
VITE_OAUTH_COLLECTION: 'ai.syui.log'
});
const config = getAppConfig();
const networkConfig = getNetworkConfig(config.atprotoPds);
const userListUrl = `${networkConfig.pdsApi}/xrpc/com.atproto.repo.listRecords?repo=${config.adminHandle}&collection=${config.collections.base}.user`;
expect(userListUrl).toBe('https://syu.is/xrpc/com.atproto.repo.listRecords?repo=ai.syui.ai&collection=ai.syui.log.user');
});
});
describe('OAuth Login Flow', () => {
it('should use syu.is OAuth for handles in VITE_ATPROTO_HANDLE_LIST', () => {
mockEnv({
VITE_ATPROTO_HANDLE_LIST: '["syui.ai", "ai.syui.ai", "yui.syui.ai"]',
VITE_ATPROTO_PDS: 'syu.is'
});
const config = getAppConfig();
const handle = 'syui.ai';
// Check if handle is in allowed list
expect(config.allowedHandles).toContain(handle);
// Should use configured PDS for OAuth
const expectedAuthUrl = `https://${config.atprotoPds}/oauth/authorize`;
expect(expectedAuthUrl).toContain('syu.is');
});
it('should use syu.is OAuth for *.syu.is handles', () => {
const handle = 'test.syu.is';
const pds = detectPdsFromHandle(handle);
expect(pds).toBe('syu.is');
});
});
});
// Terminal display test output
export function runTerminalTests() {
console.log('\n=== OAuth App Tests ===\n');
// Test 1: Handle input behavior
console.log('1. Handle Input Detection:');
const handles = ['syui.ai', 'syui.syu.is', 'syui.syui.ai'];
handles.forEach(handle => {
const pds = detectPdsFromHandle(handle);
console.log(` ${handle} → PDS: ${pds}`);
});
// Test 2: Environment variable impact
console.log('\n2. Environment Variables:');
const config = getAppConfig();
console.log(` VITE_ATPROTO_PDS: ${config.atprotoPds}`);
console.log(` VITE_ADMIN_HANDLE: ${config.adminHandle}`);
console.log(` VITE_AI_HANDLE: ${config.aiHandle}`);
console.log(` VITE_OAUTH_COLLECTION: ${config.collections.base}`);
// Test 3: API endpoints
console.log('\n3. API Endpoints:');
const networkConfig = getNetworkConfig(config.atprotoPds);
console.log(` Admin PDS API: ${networkConfig.pdsApi}`);
console.log(` Admin Bsky API: ${networkConfig.bskyApi}`);
console.log(` User list URL: ${networkConfig.pdsApi}/xrpc/com.atproto.repo.listRecords?repo=${config.adminHandle}&collection=${config.collections.base}.user`);
// Test 4: OAuth routing
console.log('\n4. OAuth Routing:');
console.log(` Allowed handles: ${JSON.stringify(config.allowedHandles)}`);
console.log(` OAuth endpoint: https://${config.atprotoPds}/oauth/authorize`);
console.log('\n=== End Tests ===\n');
}

View File

@@ -53,7 +53,6 @@ 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' }
@@ -62,7 +61,6 @@ 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
@@ -136,6 +134,5 @@ export function registerOAuthServiceWorker() {
const blob = new Blob([swCode], { type: 'application/javascript' });
const swUrl = URL.createObjectURL(blob);
navigator.serviceWorker.register(swUrl).catch(console.error);
}
}

View File

@@ -37,7 +37,6 @@ 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');
}
}
@@ -115,7 +114,6 @@ export class OAuthKeyManager {
const privateKey = await window.crypto.subtle.exportKey('jwk', keyPair.privateKey);
localStorage.setItem('oauth_private_key', JSON.stringify(privateKey));
} catch (error) {
console.error('Failed to store private key:', error);
}
}

View File

@@ -0,0 +1,348 @@
// PDS Detection and API URL mapping utilities
import { isValidDid, isValidHandle } from './validation';
export interface NetworkConfig {
pdsApi: string;
plcApi: string;
bskyApi: string;
webUrl: string;
}
// Detect PDS from handle
export function detectPdsFromHandle(handle: string): string {
// Get allowed handles from environment
const allowedHandlesStr = import.meta.env.VITE_ATPROTO_HANDLE_LIST || '[]';
let allowedHandles: string[] = [];
try {
allowedHandles = JSON.parse(allowedHandlesStr);
} catch {
allowedHandles = [];
}
// Get configured PDS from environment
const configuredPds = import.meta.env.VITE_ATPROTO_PDS || 'syu.is';
// Check if handle is in allowed list
if (allowedHandles.includes(handle)) {
return configuredPds;
}
// Check if handle ends with .syu.is or .syui.ai
if (handle.endsWith('.syu.is') || handle.endsWith('.syui.ai')) {
return 'syu.is';
}
// Check if handle ends with .bsky.social or .bsky.app
if (handle.endsWith('.bsky.social') || handle.endsWith('.bsky.app')) {
return 'bsky.social';
}
// Default to Bluesky for unknown domains
return 'bsky.social';
}
// Map PDS endpoint to network configuration
export function getNetworkConfigFromPdsEndpoint(pdsEndpoint: string): NetworkConfig {
try {
const url = new URL(pdsEndpoint);
const hostname = url.hostname;
// Map based on actual PDS endpoint
if (hostname === 'syu.is') {
return {
pdsApi: 'https://syu.is', // PDS API (repo operations)
plcApi: 'https://plc.syu.is', // PLC directory
bskyApi: 'https://bsky.syu.is', // Bluesky API (getProfile, etc.)
webUrl: 'https://web.syu.is' // Web interface
};
} else if (hostname.includes('bsky.network') || hostname === 'bsky.social' || hostname.includes('host.bsky.network')) {
// All Bluesky infrastructure (including *.host.bsky.network)
return {
pdsApi: pdsEndpoint, // Use actual PDS endpoint (e.g., shiitake.us-east.host.bsky.network)
plcApi: 'https://plc.directory', // Standard PLC directory
bskyApi: 'https://public.api.bsky.app', // Bluesky public API (NOT PDS)
webUrl: 'https://bsky.app' // Bluesky web interface
};
} else {
// Unknown PDS, assume Bluesky-compatible but use PDS for repo operations
return {
pdsApi: pdsEndpoint, // Use actual PDS for repo ops
plcApi: 'https://plc.directory', // Default PLC
bskyApi: 'https://public.api.bsky.app', // Default to Bluesky API
webUrl: 'https://bsky.app' // Default web interface
};
}
} catch (error) {
// Fallback for invalid URLs
return {
pdsApi: 'https://bsky.social',
plcApi: 'https://plc.directory',
bskyApi: 'https://public.api.bsky.app',
webUrl: 'https://bsky.app'
};
}
}
// Legacy function for backwards compatibility
export function getNetworkConfig(pds: string): NetworkConfig {
// This now assumes pds is a hostname
return getNetworkConfigFromPdsEndpoint(`https://${pds}`);
}
// Get appropriate API URL for a user based on their handle
export function getApiUrlForUser(handle: string): string {
const pds = detectPdsFromHandle(handle);
const config = getNetworkConfig(pds);
return config.bskyApi;
}
// Resolve handle/DID to actual PDS endpoint using PLC API first
export async function resolvePdsFromRepo(handleOrDid: string): Promise<{ pds: string; did: string; handle: string }> {
// Validate input
if (!handleOrDid || (!isValidDid(handleOrDid) && !isValidHandle(handleOrDid))) {
throw new Error(`Invalid identifier: ${handleOrDid}`);
}
let targetDid = handleOrDid;
let targetHandle = handleOrDid;
// If handle provided, resolve to DID first using identity.resolveHandle
if (!handleOrDid.startsWith('did:')) {
try {
// Try multiple endpoints for handle resolution
const resolveEndpoints = ['https://public.api.bsky.app', 'https://bsky.syu.is', 'https://syu.is'];
let resolved = false;
for (const endpoint of resolveEndpoints) {
try {
const resolveResponse = await fetch(`${endpoint}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handleOrDid)}`);
if (resolveResponse.ok) {
const resolveData = await resolveResponse.json();
targetDid = resolveData.did;
resolved = true;
break;
}
} catch (error) {
continue;
}
}
if (!resolved) {
throw new Error('Handle resolution failed from all endpoints');
}
} catch (error) {
throw new Error(`Failed to resolve handle ${handleOrDid} to DID: ${error}`);
}
}
// First, try PLC API to get the authoritative DID document
const plcApis = ['https://plc.directory', 'https://plc.syu.is'];
for (const plcApi of plcApis) {
try {
const plcResponse = await fetch(`${plcApi}/${targetDid}`);
if (plcResponse.ok) {
const didDocument = await plcResponse.json();
// Find PDS service in DID document
const pdsService = didDocument.service?.find((s: any) =>
s.id === '#atproto_pds' || s.type === 'AtprotoPersonalDataServer'
);
if (pdsService && pdsService.serviceEndpoint) {
return {
pds: pdsService.serviceEndpoint,
did: targetDid,
handle: targetHandle
};
}
}
} catch (error) {
continue;
}
}
// Fallback: use com.atproto.repo.describeRepo to get PDS from known PDS endpoints
const pdsEndpoints = ['https://bsky.social', 'https://syu.is'];
for (const pdsEndpoint of pdsEndpoints) {
try {
const response = await fetch(`${pdsEndpoint}/xrpc/com.atproto.repo.describeRepo?repo=${encodeURIComponent(targetDid)}`);
if (response.ok) {
const data = await response.json();
// Extract PDS from didDoc.service
const services = data.didDoc?.service || [];
const pdsService = services.find((s: any) =>
s.id === '#atproto_pds' || s.type === 'AtprotoPersonalDataServer'
);
if (pdsService) {
return {
pds: pdsService.serviceEndpoint,
did: data.did || targetDid,
handle: data.handle || targetHandle
};
}
}
} catch (error) {
continue;
}
}
throw new Error(`Failed to resolve PDS for ${handleOrDid} from any endpoint`);
}
// Resolve DID to actual PDS endpoint using com.atproto.repo.describeRepo
export async function resolvePdsFromDid(did: string): Promise<string> {
const resolved = await resolvePdsFromRepo(did);
return resolved.pds;
}
// Enhanced resolve handle to DID with proper PDS detection
export async function resolveHandleToDid(handle: string): Promise<{ did: string; pds: string }> {
try {
// First, try to resolve the handle to DID using multiple methods
const apiUrl = getApiUrlForUser(handle);
const response = await fetch(`${apiUrl}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`);
if (!response.ok) {
throw new Error(`Failed to resolve handle: ${response.status}`);
}
const data = await response.json();
const did = data.did;
// Now resolve the actual PDS from the DID
const actualPds = await resolvePdsFromDid(did);
return {
did: did,
pds: actualPds
};
} catch (error) {
// Failed to resolve handle
// Fallback to handle-based detection
const fallbackPds = detectPdsFromHandle(handle);
throw error;
}
}
// Get profile using appropriate API for the user with accurate PDS resolution
export async function getProfileForUser(handleOrDid: string, knownPdsEndpoint?: string): Promise<any> {
try {
let apiUrl: string;
if (knownPdsEndpoint) {
// If we already know the user's PDS endpoint, use it directly
const config = getNetworkConfigFromPdsEndpoint(knownPdsEndpoint);
apiUrl = config.bskyApi;
} else {
// Resolve the user's actual PDS using describeRepo
try {
const resolved = await resolvePdsFromRepo(handleOrDid);
const config = getNetworkConfigFromPdsEndpoint(resolved.pds);
apiUrl = config.bskyApi;
} catch {
// Fallback to handle-based detection
apiUrl = getApiUrlForUser(handleOrDid);
}
}
const response = await fetch(`${apiUrl}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handleOrDid)}`);
if (!response.ok) {
throw new Error(`Failed to get profile: ${response.status}`);
}
return await response.json();
} catch (error) {
// Failed to get profile
// Final fallback: try with default Bluesky API
try {
const response = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handleOrDid)}`);
if (response.ok) {
return await response.json();
}
} catch {
// Ignore fallback errors
}
throw error;
}
}
// Test and verify PDS detection methods
export async function verifyPdsDetection(handleOrDid: string): Promise<void> {
try {
// Method 1: com.atproto.repo.describeRepo (PRIMARY METHOD)
try {
const resolved = await resolvePdsFromRepo(handleOrDid);
const config = getNetworkConfigFromPdsEndpoint(resolved.pds);
} catch (error) {
// describeRepo failed
}
// Method 2: com.atproto.identity.resolveHandle (for comparison)
if (!handleOrDid.startsWith('did:')) {
try {
const resolveResponse = await fetch(`https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handleOrDid)}`);
if (resolveResponse.ok) {
const resolveData = await resolveResponse.json();
}
} catch (error) {
// Error resolving handle
}
}
// Method 3: PLC Directory lookup (if we have a DID)
let targetDid = handleOrDid;
if (!handleOrDid.startsWith('did:')) {
try {
const profile = await getProfileForUser(handleOrDid);
targetDid = profile.did;
} catch {
return;
}
}
try {
const plcResponse = await fetch(`https://plc.directory/${targetDid}`);
if (plcResponse.ok) {
const didDocument = await plcResponse.json();
// Find PDS service
const pdsService = didDocument.service?.find((s: any) =>
s.id === '#atproto_pds' || s.type === 'AtprotoPersonalDataServer'
);
if (pdsService) {
// Try to detect if this is a known network
const pdsUrl = pdsService.serviceEndpoint;
const hostname = new URL(pdsUrl).hostname;
const detectedNetwork = detectPdsFromHandle(`user.${hostname}`);
const networkConfig = getNetworkConfig(hostname);
}
}
} catch (error) {
// Error fetching from PLC directory
}
// Method 4: Our enhanced resolution
try {
if (handleOrDid.startsWith('did:')) {
const pdsEndpoint = await resolvePdsFromDid(handleOrDid);
} else {
const resolved = await resolveHandleToDid(handleOrDid);
}
} catch (error) {
// Enhanced resolution failed
}
} catch (error) {
// Overall verification failed
}
}

View File

@@ -0,0 +1,21 @@
// Validation utilities for atproto identifiers
export function isValidDid(did: string): boolean {
if (!did || typeof did !== 'string') return false;
// Basic DID format: did:method:identifier
const didRegex = /^did:[a-z]+:[a-zA-Z0-9._%-]+$/;
return didRegex.test(did);
}
export function isValidHandle(handle: string): boolean {
if (!handle || typeof handle !== 'string') return false;
// Basic handle format: subdomain.domain.tld
const handleRegex = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/;
return handleRegex.test(handle);
}
export function isValidAtprotoIdentifier(identifier: string): boolean {
return isValidDid(identifier) || isValidHandle(identifier);
}

10
oauth_new/.env Normal file
View File

@@ -0,0 +1,10 @@
VITE_ADMIN=ai.syui.ai
VITE_PDS=syu.is
VITE_HANDLE_LIST=["ai.syui.ai", "syui.syui.ai", "ai.ai"]
VITE_COLLECTION=ai.syui.log
VITE_OAUTH_CLIENT_ID=https://syui.ai/client-metadata.json
VITE_OAUTH_REDIRECT_URI=https://syui.ai/oauth/callback
# Development/Debug features
VITE_ENABLE_TEST_UI=true
VITE_ENABLE_DEBUG=true

10
oauth_new/.env.production Normal file
View File

@@ -0,0 +1,10 @@
VITE_ADMIN=ai.syui.ai
VITE_PDS=syu.is
VITE_HANDLE_LIST=["ai.syui.ai", "syui.syui.ai", "ai.ai"]
VITE_COLLECTION=ai.syui.log
VITE_OAUTH_CLIENT_ID=https://syui.ai/client-metadata.json
VITE_OAUTH_REDIRECT_URI=https://syui.ai/oauth/callback
# Production settings - Disable development features
VITE_ENABLE_TEST_UI=false
VITE_ENABLE_DEBUG=false

View File

@@ -0,0 +1,116 @@
# Ask-AI Integration Implementation
## 概要
oauth_new アプリに ask-AI 機能を統合しました。この機能により、ユーザーはAIと対話し、その結果を atproto に記録できます。
## 実装されたファイル
### 1. `/src/hooks/useAskAI.js`
- ask-AI サーバーとの通信機能
- atproto への putRecord 機能
- チャット履歴の管理
- イベント送信blog との通信用)
### 2. `/src/components/AskAI.jsx`
- チャット UI コンポーネント
- 質問入力・回答表示
- 認証チェック
- IME 対応
### 3. `/src/App.jsx` の更新
- AskAI コンポーネントの統合
- Ask AI ボタンの追加
- イベントリスナーの設定
- blog との通信機能
## JSON 構造の記録
`./json/` ディレクトリに各 collection の構造を記録しました:
- `ai.syui.ai_user.json` - ユーザーリスト
- `ai.syui.ai_chat.json` - チャット記録(空)
- `syui.syui.ai_chat.json` - チャット記録(実データ)
- `ai.syui.ai_chat_lang.json` - 翻訳記録
- `ai.syui.ai_chat_comment.json` - コメント記録
## 実際の ai.syui.log.chat 構造
確認された実際の構造:
```json
{
"$type": "ai.syui.log.chat",
"post": {
"url": "https://syui.ai/",
"date": "2025-06-18T02:16:04.609Z",
"slug": "",
"tags": [],
"title": "syui.ai",
"language": "ja"
},
"text": "質問またはAI回答テキスト",
"type": "question|answer",
"author": {
"did": "did:plc:...",
"handle": "handle名",
"displayName": "表示名",
"avatar": "アバターURL"
},
"createdAt": "2025-06-18T02:16:04.609Z"
}
```
## イベント通信
blogask-ai.jsと OAuth アプリ間の通信:
### 送信イベント
- `postAIQuestion` - blog から OAuth アプリへ質問送信
- `aiProfileLoaded` - OAuth アプリから blog へ AI プロフィール送信
- `aiResponseReceived` - OAuth アプリから blog へ AI 回答送信
### 受信イベント
- OAuth アプリが `postAIQuestion` を受信して処理
- blog が `aiResponseReceived` を受信して表示
## 環境変数
```env
VITE_ASK_AI_URL=http://localhost:3000/ask # ask-AI サーバーURLデフォルト
VITE_ADMIN_HANDLE=ai.syui.ai
VITE_ATPROTO_PDS=syu.is
VITE_OAUTH_COLLECTION=ai.syui.log
```
## 機能
### 実装済み
- ✅ ask-AI サーバーとの通信
- ✅ atproto への question/answer record 保存
- ✅ チャット履歴の表示・管理
- ✅ blog との双方向イベント通信
- ✅ 認証機能(ログイン必須)
- ✅ エラーハンドリング・ローディング状態
- ✅ 実際の JSON 構造に合わせた実装
### 今後のテスト項目
- ask-AI サーバーの準備・起動
- 実際の質問送信テスト
- atproto への putRecord 動作確認
- blog からの連携テスト
## 使用方法
1. 開発サーバー起動: `npm run dev`
2. OAuth ログイン実行
3. "Ask AI" ボタンをクリック
4. チャット画面で質問入力
5. AI の回答が表示され、atproto に記録される
## 注意事項
- ask-AI サーバーVITE_ASK_AI_URLが必要
- 認証されたユーザーのみ質問可能
- ai.syui.log.chat への書き込み権限が必要
- Production 環境では logger が無効化される

174
oauth_new/AVATAR_SYSTEM.md Normal file
View File

@@ -0,0 +1,174 @@
# Avatar Fetching System
This document describes the avatar fetching system implemented for the oauth_new application.
## Overview
The avatar system provides intelligent avatar fetching with fallback mechanisms, caching, and error handling. It follows the design specified in the project instructions:
1. **Primary Source**: Try to use avatar from record JSON first
2. **Fallback**: If avatar is broken/missing, fetch fresh data from ATProto
3. **Fresh Data Flow**: handle → PDS → DID → profile → avatar URI
4. **Caching**: Avoid excessive API calls with intelligent caching
## Files Structure
```
src/
├── utils/
│ └── avatar.js # Core avatar fetching logic
├── components/
│ ├── Avatar.jsx # React avatar component
│ └── AvatarTest.jsx # Test component
└── App.css # Avatar styling
```
## Core Functions
### `getAvatar(options)`
Main function to fetch avatar with intelligent fallback.
```javascript
const avatar = await getAvatar({
record: recordObject, // Optional: record containing avatar data
handle: 'user.handle', // Required if no record
did: 'did:plc:xxx', // Optional: user DID
forceFresh: false // Optional: force fresh fetch
})
```
### `batchFetchAvatars(users)`
Fetch avatars for multiple users in parallel with concurrency control.
```javascript
const avatarMap = await batchFetchAvatars([
{ handle: 'user1.handle', did: 'did:plc:xxx1' },
{ handle: 'user2.handle', did: 'did:plc:xxx2' }
])
```
### `prefetchAvatar(handle)`
Prefetch and cache avatar for a specific handle.
```javascript
await prefetchAvatar('user.handle')
```
## React Components
### `<Avatar>`
Basic avatar component with loading states and fallbacks.
```jsx
<Avatar
record={record}
handle="user.handle"
did="did:plc:xxx"
size={40}
showFallback={true}
onLoad={() => console.log('loaded')}
onError={(err) => console.log('error', err)}
/>
```
### `<AvatarWithCard>`
Avatar with hover card showing user information.
```jsx
<AvatarWithCard
record={record}
displayName="User Name"
apiConfig={apiConfig}
size={60}
/>
```
### `<AvatarList>`
Display multiple avatars with overlap effect.
```jsx
<AvatarList
users={userArray}
maxDisplay={5}
size={30}
/>
```
## Data Flow
1. **Record Check**: Extract avatar from record.value.author.avatar
2. **URL Validation**: Verify avatar URL is accessible (HEAD request)
3. **Fresh Fetch**: If broken, fetch fresh data:
- Get PDS from handle using `getPdsFromHandle()`
- Get API config using `getApiConfig()`
- Get DID from PDS using `atproto.getDid()`
- Get profile from bsky API using `atproto.getProfile()`
- Extract avatar from profile
4. **Cache**: Store result in cache with 30-minute TTL
5. **Fallback**: Show initial-based fallback if no avatar found
## Caching Strategy
- **Cache Key**: `avatar:{handle}`
- **Duration**: 30 minutes (configurable)
- **Cache Provider**: Uses existing `dataCache` utility
- **Invalidation**: Manual cache clearing functions available
## Error Handling
- **Network Errors**: Gracefully handled with fallback UI
- **Broken URLs**: Automatically detected and re-fetched fresh
- **Missing Handles**: Throws descriptive error messages
- **API Failures**: Logged but don't break UI
## Integration
The avatar system is integrated into the existing RecordList component:
```jsx
// Old approach
{record.value.author?.avatar && (
<img src={record.value.author.avatar} alt="avatar" className="avatar" />
)}
// New approach
<Avatar
record={record}
handle={record.value.author?.handle}
did={record.value.author?.did}
size={40}
showFallback={true}
/>
```
## Testing
The system includes a comprehensive test component (`AvatarTest.jsx`) that can be accessed through the Test UI in the app. It demonstrates:
1. Avatar from record data
2. Avatar from handle only
3. Broken avatar URL handling
4. Batch fetching
5. Prefetch functionality
6. Various avatar components
To test:
1. Open the app
2. Click "Test" button in header
3. Switch to "Avatar System" tab
4. Use the test controls to verify functionality
## Performance Considerations
- **Concurrent Fetching**: Batch operations use concurrency limits (5 parallel requests)
- **Caching**: Reduces API calls by caching results
- **Lazy Loading**: Avatar images use lazy loading
- **Error Recovery**: Broken avatars are automatically retried with fresh data
## Future Enhancements
1. **Persistent Cache**: Consider localStorage for cross-session caching
2. **Image Optimization**: Add WebP support and size optimization
3. **Preloading**: Implement smarter preloading strategies
4. **CDN Integration**: Add CDN support for avatar delivery
5. **Placeholder Variations**: More diverse fallback avatar styles

View File

@@ -0,0 +1,420 @@
# Avatar System Usage Examples
This document provides practical examples of how to use the avatar fetching system in your components.
## Basic Usage
### Simple Avatar Display
```jsx
import Avatar from './components/Avatar.jsx'
function UserProfile({ user }) {
return (
<div className="user-profile">
<Avatar
handle={user.handle}
did={user.did}
size={80}
alt={`${user.displayName}'s avatar`}
/>
<h3>{user.displayName}</h3>
</div>
)
}
```
### Avatar from Record Data
```jsx
function CommentItem({ record }) {
return (
<div className="comment">
<Avatar
record={record}
size={40}
showFallback={true}
/>
<div className="comment-content">
<strong>{record.value.author.displayName}</strong>
<p>{record.value.text}</p>
</div>
</div>
)
}
```
### Avatar with Hover Card
```jsx
import { AvatarWithCard } from './components/Avatar.jsx'
function UserList({ users, apiConfig }) {
return (
<div className="user-list">
{users.map(user => (
<AvatarWithCard
key={user.handle}
handle={user.handle}
did={user.did}
displayName={user.displayName}
apiConfig={apiConfig}
size={50}
/>
))}
</div>
)
}
```
## Advanced Usage
### Programmatic Avatar Fetching
```jsx
import { useEffect, useState } from 'react'
import { getAvatar, batchFetchAvatars } from './utils/avatar.js'
function useUserAvatars(users) {
const [avatars, setAvatars] = useState(new Map())
const [loading, setLoading] = useState(false)
useEffect(() => {
async function fetchAvatars() {
setLoading(true)
try {
const avatarMap = await batchFetchAvatars(users)
setAvatars(avatarMap)
} catch (error) {
console.error('Failed to fetch avatars:', error)
} finally {
setLoading(false)
}
}
if (users.length > 0) {
fetchAvatars()
}
}, [users])
return { avatars, loading }
}
// Usage
function TeamDisplay({ team }) {
const { avatars, loading } = useUserAvatars(team.members)
if (loading) return <div>Loading team...</div>
return (
<div className="team">
{team.members.map(member => (
<img
key={member.handle}
src={avatars.get(member.handle) || '/default-avatar.png'}
alt={member.displayName}
/>
))}
</div>
)
}
```
### Force Refresh Avatar
```jsx
import { useState } from 'react'
import Avatar from './components/Avatar.jsx'
import { getAvatar, clearAvatarCache } from './utils/avatar.js'
function RefreshableAvatar({ handle, did }) {
const [key, setKey] = useState(0)
const handleRefresh = async () => {
// Clear cache for this user
clearAvatarCache(handle)
// Force re-render of Avatar component
setKey(prev => prev + 1)
// Optionally, prefetch fresh avatar
try {
await getAvatar({ handle, did, forceFresh: true })
} catch (error) {
console.error('Failed to refresh avatar:', error)
}
}
return (
<div className="refreshable-avatar">
<Avatar
key={key}
handle={handle}
did={did}
size={60}
/>
<button onClick={handleRefresh}>
Refresh Avatar
</button>
</div>
)
}
```
### Avatar List with Overflow
```jsx
import { AvatarList } from './components/Avatar.jsx'
function ParticipantsList({ participants, maxVisible = 5 }) {
return (
<div className="participants">
<h4>Participants ({participants.length})</h4>
<AvatarList
users={participants}
maxDisplay={maxVisible}
size={32}
/>
{participants.length > maxVisible && (
<span className="overflow-text">
and {participants.length - maxVisible} more...
</span>
)}
</div>
)
}
```
## Error Handling
### Custom Error Handling
```jsx
import { useState } from 'react'
import Avatar from './components/Avatar.jsx'
function RobustAvatar({ handle, did, fallbackSrc }) {
const [hasError, setHasError] = useState(false)
const handleError = (error) => {
console.warn(`Avatar failed for ${handle}:`, error)
setHasError(true)
}
if (hasError && fallbackSrc) {
return (
<img
src={fallbackSrc}
alt="Fallback avatar"
className="avatar"
onError={() => setHasError(false)} // Reset on fallback error
/>
)
}
return (
<Avatar
handle={handle}
did={did}
onError={handleError}
showFallback={!hasError}
/>
)
}
```
### Loading States
```jsx
import { useState, useEffect } from 'react'
import { getAvatar } from './utils/avatar.js'
function AvatarWithCustomLoading({ handle, did }) {
const [avatar, setAvatar] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
async function loadAvatar() {
try {
setLoading(true)
setError(null)
const avatarUrl = await getAvatar({ handle, did })
setAvatar(avatarUrl)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
loadAvatar()
}, [handle, did])
if (loading) {
return <div className="avatar-loading-spinner">Loading...</div>
}
if (error) {
return <div className="avatar-error">Failed to load avatar</div>
}
if (!avatar) {
return <div className="avatar-placeholder">No avatar</div>
}
return <img src={avatar} alt="Avatar" className="avatar" />
}
```
## Optimization Patterns
### Preloading Strategy
```jsx
import { useEffect } from 'react'
import { prefetchAvatar } from './utils/avatar.js'
function UserCard({ user, isVisible }) {
// Preload avatar when component becomes visible
useEffect(() => {
if (isVisible && user.handle) {
prefetchAvatar(user.handle)
}
}, [isVisible, user.handle])
return (
<div className="user-card">
{isVisible && (
<Avatar handle={user.handle} did={user.did} />
)}
<h4>{user.displayName}</h4>
</div>
)
}
```
### Lazy Loading with Intersection Observer
```jsx
import { useState, useEffect, useRef } from 'react'
import Avatar from './components/Avatar.jsx'
function LazyAvatar({ handle, did, ...props }) {
const [isVisible, setIsVisible] = useState(false)
const ref = useRef()
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true)
observer.disconnect()
}
},
{ threshold: 0.1 }
)
if (ref.current) {
observer.observe(ref.current)
}
return () => observer.disconnect()
}, [])
return (
<div ref={ref}>
{isVisible ? (
<Avatar handle={handle} did={did} {...props} />
) : (
<div className="avatar-placeholder" style={{
width: props.size || 40,
height: props.size || 40
}} />
)}
</div>
)
}
```
## Cache Management
### Cache Statistics Display
```jsx
import { useEffect, useState } from 'react'
import { getAvatarCacheStats, cleanupExpiredAvatars } from './utils/avatarCache.js'
function CacheStatsPanel() {
const [stats, setStats] = useState(null)
useEffect(() => {
const updateStats = () => {
setStats(getAvatarCacheStats())
}
updateStats()
const interval = setInterval(updateStats, 5000) // Update every 5 seconds
return () => clearInterval(interval)
}, [])
const handleCleanup = async () => {
const cleaned = cleanupExpiredAvatars()
alert(`Cleaned ${cleaned} expired cache entries`)
setStats(getAvatarCacheStats())
}
if (!stats) return null
return (
<div className="cache-stats">
<h4>Avatar Cache Stats</h4>
<p>Cached avatars: {stats.totalCached}</p>
<p>Cache hit rate: {stats.hitRate}%</p>
<p>Cache hits: {stats.cacheHits}</p>
<p>Cache misses: {stats.cacheMisses}</p>
<button onClick={handleCleanup}>
Clean Expired Cache
</button>
</div>
)
}
```
## Testing Helpers
### Mock Avatar for Testing
```jsx
// For testing environments
const MockAvatar = ({ handle, size = 40, showFallback = true }) => {
if (!showFallback) return null
const initial = (handle || 'U')[0].toUpperCase()
return (
<div
className="avatar-mock"
style={{
width: size,
height: size,
borderRadius: '50%',
backgroundColor: '#e1e1e1',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: size * 0.4,
color: '#666'
}}
>
{initial}
</div>
)
}
// Use in tests
export default process.env.NODE_ENV === 'test' ? MockAvatar : Avatar
```
These examples demonstrate the flexibility and power of the avatar system while maintaining good performance and user experience practices.

View File

@@ -0,0 +1,57 @@
name: Deploy to Cloudflare Pages
on:
push:
branches:
- main
workflow_dispatch:
env:
OAUTH_DIR: oauth_new
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: ${{ env.OAUTH_DIR }}/package-lock.json
- name: Install dependencies
run: |
cd ${{ env.OAUTH_DIR }}
npm ci
- name: Build OAuth app
run: |
cd ${{ env.OAUTH_DIR }}
NODE_ENV=production npm run build
env:
VITE_ADMIN: ${{ secrets.VITE_ADMIN }}
VITE_PDS: ${{ secrets.VITE_PDS }}
VITE_HANDLE_LIST: ${{ secrets.VITE_HANDLE_LIST }}
VITE_COLLECTION: ${{ secrets.VITE_COLLECTION }}
VITE_OAUTH_CLIENT_ID: ${{ secrets.VITE_OAUTH_CLIENT_ID }}
VITE_OAUTH_REDIRECT_URI: ${{ secrets.VITE_OAUTH_REDIRECT_URI }}
VITE_ENABLE_TEST_UI: 'false'
VITE_ENABLE_DEBUG: 'false'
- 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: ${{ env.OAUTH_DIR }}/dist
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
deploymentName: Production

View File

@@ -0,0 +1,104 @@
name: Deploy to Cloudflare Pages
on:
push:
branches:
- main
workflow_dispatch:
env:
OAUTH_DIR: oauth_new
KEEP_DEPLOYMENTS: 5 # 保持するデプロイメント数
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: ${{ env.OAUTH_DIR }}/package-lock.json
- name: Install dependencies
run: |
cd ${{ env.OAUTH_DIR }}
npm ci
- name: Build OAuth app
run: |
cd ${{ env.OAUTH_DIR }}
NODE_ENV=production npm run build
env:
VITE_ADMIN: ${{ secrets.VITE_ADMIN }}
VITE_PDS: ${{ secrets.VITE_PDS }}
VITE_HANDLE_LIST: ${{ secrets.VITE_HANDLE_LIST }}
VITE_COLLECTION: ${{ secrets.VITE_COLLECTION }}
VITE_OAUTH_CLIENT_ID: ${{ secrets.VITE_OAUTH_CLIENT_ID }}
VITE_OAUTH_REDIRECT_URI: ${{ secrets.VITE_OAUTH_REDIRECT_URI }}
VITE_ENABLE_TEST_UI: 'false'
VITE_ENABLE_DEBUG: 'false'
- 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: ${{ env.OAUTH_DIR }}/dist
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
deploymentName: Production
cleanup:
needs: deploy
runs-on: ubuntu-latest
if: success()
steps:
- name: Wait for deployment to complete
run: sleep 30
- name: Cleanup old deployments
run: |
# Get all deployments
DEPLOYMENTS=$(curl -s -X GET \
"https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${{ secrets.CLOUDFLARE_PROJECT_NAME }}/deployments" \
-H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \
-H "Content-Type: application/json")
# Extract deployment IDs (skip the latest N deployments)
DEPLOYMENT_IDS=$(echo "$DEPLOYMENTS" | jq -r ".result | sort_by(.created_on) | reverse | .[${{ env.KEEP_DEPLOYMENTS }}:] | .[].id // empty")
if [ -z "$DEPLOYMENT_IDS" ]; then
echo "No old deployments to delete"
exit 0
fi
# Delete old deployments
for ID in $DEPLOYMENT_IDS; do
echo "Deleting deployment: $ID"
RESPONSE=$(curl -s -X DELETE \
"https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${{ secrets.CLOUDFLARE_PROJECT_NAME }}/deployments/$ID" \
-H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \
-H "Content-Type: application/json")
SUCCESS=$(echo "$RESPONSE" | jq -r '.success')
if [ "$SUCCESS" = "true" ]; then
echo "Successfully deleted deployment: $ID"
else
echo "Failed to delete deployment: $ID"
echo "$RESPONSE" | jq .
fi
sleep 1 # Rate limiting
done
echo "Cleanup completed!"

178
oauth_new/DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,178 @@
# 本番環境デプロイメント手順
## 本番環境用の調整
### 1. テスト機能の削除・無効化
本番環境では以下の調整が必要です:
#### A. TestUI コンポーネントの削除
```jsx
// src/App.jsx から以下を削除/コメントアウト
import TestUI from './components/TestUI.jsx'
const [showTestUI, setShowTestUI] = useState(false)
// ボトムセクションからTestUIを削除
{showTestUI && (
<TestUI />
)}
<button
onClick={() => setShowTestUI(!showTestUI)}
className={`btn ${showTestUI ? 'btn-danger' : 'btn-outline'} btn-sm`}
>
{showTestUI ? 'close test' : 'test'}
</button>
```
#### B. ログ出力の完全無効化
現在は `logger.js` で開発環境のみログが有効になっていますが、完全に確実にするため:
```bash
# 本番ビルド前に全てのconsole.logを確認
grep -r "console\." src/ --exclude-dir=node_modules
```
### 2. 環境変数の設定
#### 本番用 .env.production
```bash
VITE_ATPROTO_PDS=syu.is
VITE_ADMIN_HANDLE=ai.syui.ai
VITE_AI_HANDLE=ai.syui.ai
VITE_OAUTH_COLLECTION=ai.syui.log
```
### 3. ビルドコマンド
```bash
# 本番用ビルド
npm run build
# 生成されるファイル確認
ls -la dist/
```
### 4. デプロイ用ファイル構成
```
dist/
├── index.html # 最小化HTML
├── assets/
│ ├── comment-atproto-[hash].js # メインJSバンドル
│ └── comment-atproto-[hash].css # CSS
```
### 5. ailog サイトへの統合
#### A. アセットファイルのコピー
```bash
# distファイルをailogサイトの適切な場所にコピー
cp dist/assets/* /path/to/ailog/static/assets/
cp dist/index.html /path/to/ailog/templates/oauth-assets.html
```
#### B. ailog テンプレートでの読み込み
```html
<!-- ailog のテンプレートに追加 -->
{{ if .Site.Params.oauth_comments }}
{{ partial "oauth-assets.html" . }}
{{ end }}
```
### 6. 本番環境チェックリスト
#### ✅ セキュリティ
- [ ] OAuth認証のリダイレクトURL確認
- [ ] 環境変数の機密情報確認
- [ ] HTTPS通信確認
#### ✅ パフォーマンス
- [ ] バンドルサイズ確認現在1.2MB
- [ ] ファイル圧縮確認
- [ ] キャッシュ設定確認
#### ✅ 機能
- [ ] 本番PDS接続確認
- [ ] OAuth認証フロー確認
- [ ] コメント投稿・表示確認
- [ ] アバター表示確認
#### ✅ UI/UX
- [ ] モバイル表示確認
- [ ] アクセシビリティ確認
- [ ] エラーハンドリング確認
### 7. 段階的デプロイ戦略
#### Phase 1: テスト環境
```bash
# テスト用のサブドメインでデプロイ
# test.syui.ai など
```
#### Phase 2: 本番環境
```bash
# 問題なければ本番環境にデプロイ
# ailog本体に統合
```
### 8. トラブルシューティング
#### よくある問題
1. **OAuth認証エラー**: リダイレクトURL設定確認
2. **PDS接続エラー**: ネットワーク・DNS設定確認
3. **アバター表示エラー**: CORS設定確認
4. **CSS競合**: oauth-プレフィックス確認
#### ログ確認方法
```bash
# 本番環境でエラーが発生した場合
# ブラウザのDevToolsでエラー確認
# logger.jsは本番では無効化されている
```
### 9. 本番用設定ファイル
```bash
# ~/.config/syui/ai/log/config.json
{
"oauth": {
"environment": "production",
"debug": false,
"test_mode": false
}
}
```
### 10. 推奨デプロイ手順
```bash
# 1. テスト機能削除
git checkout -b production-ready
# App.jsx からTestUI関連を削除
# 2. 本番ビルド
npm run build
# 3. ファイル確認
ls -la dist/
# 4. ailogサイトに統合
cp dist/assets/* ../my-blog/static/assets/
cp dist/index.html ../my-blog/templates/oauth-assets.html
# 5. ailogサイトでテスト
cd ../my-blog
hugo server
# 6. 問題なければcommit
git add .
git commit -m "Production build: Remove test UI, optimize for deployment"
```
## 注意事項
- TestUIは開発・デモ用のため本番では削除必須
- loggerは自動で本番では無効化される
- OAuth設定は本番PDS用に調整必要
- バンドルサイズが大きいため今後最適化検討

334
oauth_new/DEVELOPMENT.md Normal file
View File

@@ -0,0 +1,334 @@
# 開発ガイド
## 設計思想
このプロジェクトは以下の原則に基づいて設計されています:
### 1. 環境変数による設定の外部化
- ハードコードを避け、設定は全て環境変数で管理
- `src/config/env.js` で一元管理
### 2. PDSPersonal Data Serverの自動判定
- `VITE_HANDLE_LIST``VITE_PDS` による自動判定
- syu.is系とbsky.social系の自動振り分け
### 3. コンポーネントの責任分離
- Hooks: ビジネスロジック
- Components: UI表示のみ
- Services: 外部API連携
- Utils: 純粋関数
## アーキテクチャ詳細
### データフロー
```
User Input
Hooks (useAuth, useAdminData, usePageContext)
Services (OAuthService)
API (atproto.js)
ATProto Network
Components (UI Display)
```
### 状態管理
React Hooksによる状態管理
- `useAuth`: OAuth認証状態
- `useAdminData`: 管理者データ(プロフィール、レコード)
- `usePageContext`: ページ判定(トップ/個別)
### OAuth認証フロー
```
1. ユーザーがハンドル入力
2. PDS判定 (syu.is vs bsky.social)
3. 適切なOAuthClientを選択
4. 標準OAuth画面にリダイレクト
5. 認証完了後コールバック処理
6. セッション復元・保存
```
## 重要な実装詳細
### セッション管理
`@atproto/oauth-client-browser`が自動的に以下を処理:
- IndexedDBへのセッション保存
- トークンの自動更新
- DPoPDemonstration of Proof of Possession
**注意**: 手動でのセッション管理は複雑なため、公式ライブラリを使用すること。
### PDS判定アルゴリズム
```javascript
// src/utils/pds.js
function isSyuIsHandle(handle) {
return env.handleList.includes(handle) || handle.endsWith(`.${env.pds}`)
}
```
1. `VITE_HANDLE_LIST` に含まれるハンドル → syu.is
2. `.syu.is` で終わるハンドル → syu.is
3. その他 → bsky.social
### レコードフィルタリング
```javascript
// src/components/RecordTabs.jsx
const filterRecords = (records) => {
if (pageContext.isTopPage) {
return records.slice(0, 3) // 最新3件
} else {
// URL のrkey と record.value.post.url のrkey を照合
return records.filter(record => {
const recordRkey = new URL(record.value.post.url).pathname.split('/').pop()?.replace(/\.html$/, '')
return recordRkey === pageContext.rkey
})
}
}
```
## 開発時の注意点
### 1. 環境変数の命名
- `VITE_` プレフィックス必須Viteの制約
- JSON形式の環境変数は文字列として定義
```bash
# ❌ 間違い
VITE_HANDLE_LIST=["ai.syui.ai"]
# ✅ 正しい
VITE_HANDLE_LIST=["ai.syui.ai", "syui.syui.ai"]
```
### 2. API エラーハンドリング
```javascript
// src/api/atproto.js
async function request(url) {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
return await response.json()
}
```
すべてのAPI呼び出しでエラーハンドリングを実装。
### 3. コンポーネント設計
```javascript
// ❌ Bad: ビジネスロジックがコンポーネント内
function MyComponent() {
const [data, setData] = useState([])
useEffect(() => {
fetch('/api/data').then(setData)
}, [])
return <div>{data.map(...)}</div>
}
// ✅ Good: Hooksでロジック分離
function MyComponent() {
const { data, loading, error } = useMyData()
if (loading) return <Loading />
if (error) return <Error />
return <div>{data.map(...)}</div>
}
```
## デバッグ手法
### 1. OAuth デバッグ
```javascript
// ブラウザの開発者ツールで確認
localStorage.clear() // セッションクリア
sessionStorage.clear() // 一時データクリア
// IndexedDB確認Application タブ)
// ATProtoの認証データが保存される
```
### 2. PDS判定デバッグ
```javascript
// src/utils/pds.js にログ追加
console.log('Handle:', handle)
console.log('Is syu.is:', isSyuIsHandle(handle))
console.log('API Config:', getApiConfig(pds))
```
### 3. レコードフィルタリングデバッグ
```javascript
// src/components/RecordTabs.jsx
console.log('Page Context:', pageContext)
console.log('All Records:', records.length)
console.log('Filtered Records:', filteredRecords.length)
```
## パフォーマンス最適化
### 1. 並列データ取得
```javascript
// src/hooks/useAdminData.js
const [records, lang, comment] = await Promise.all([
collections.getBase(apiConfig.pds, did, env.collection),
collections.getLang(apiConfig.pds, did, env.collection),
collections.getComment(apiConfig.pds, did, env.collection)
])
```
### 2. 不要な再レンダリング防止
```javascript
// useMemo でフィルタリング結果をキャッシュ
const filteredRecords = useMemo(() =>
filterRecords(records),
[records, pageContext]
)
```
## テスト戦略
### 1. 単体テスト推奨対象
- `src/utils/pds.js` - PDS判定ロジック
- `src/config/env.js` - 環境変数パース
- フィルタリング関数
### 2. 統合テスト推奨対象
- OAuth認証フロー
- API呼び出し
- レコード表示
## デプロイメント
### 1. 必要ファイル
```
public/
└── client-metadata.json # OAuth設定ファイル
dist/ # ビルド出力
├── index.html
└── assets/
├── comment-atproto-[hash].js
└── comment-atproto-[hash].css
```
### 2. デプロイ手順
```bash
# 1. 環境変数設定
cp .env.example .env
# 2. 本番用設定を記入
# 3. ビルド
npm run build
# 4. dist/ フォルダをデプロイ
```
### 3. 本番環境チェックリスト
- [ ] `.env` ファイルの本番設定
- [ ] `client-metadata.json` の設置
- [ ] HTTPS 必須OAuth要件
- [ ] CSPContent Security Policy設定
## よくある問題と解決法
### 1. "OAuth initialization failed"
**原因**: client-metadata.json が見つからない、または形式が正しくない
**解決法**:
```bash
# public/client-metadata.json の存在確認
ls -la public/client-metadata.json
# 形式確認JSON validation
jq . public/client-metadata.json
```
### 2. "Failed to load admin data"
**原因**: 管理者アカウントのDID解決に失敗
**解決法**:
```bash
# 手動でDID解決確認
curl "https://syu.is/xrpc/com.atproto.repo.describeRepo?repo=ai.syui.ai"
```
### 3. レコードが表示されない
**原因**: コレクション名の不一致、権限不足
**解決法**:
```bash
# コレクション確認
curl "https://syu.is/xrpc/com.atproto.repo.listRecords?repo=did:plc:xxx&collection=ai.syui.log.chat.lang"
```
## 機能拡張ガイド
### 1. 新しいコレクション追加
```javascript
// src/api/atproto.js に追加
export const collections = {
// 既存...
async getNewCollection(pds, repo, collection, limit = 10) {
return await atproto.getRecords(pds, repo, `${collection}.new`, limit)
}
}
```
### 2. 新しいPDS対応
```javascript
// src/utils/pds.js を拡張
export function getApiConfig(pds) {
if (pds.includes('syu.is')) {
// 既存の syu.is 設定
} else if (pds.includes('newpds.com')) {
return {
pds: `https://newpds.com`,
bsky: `https://bsky.newpds.com`,
plc: `https://plc.newpds.com`,
web: `https://web.newpds.com`
}
}
// デフォルト設定
}
```
### 3. リアルタイム更新追加
```javascript
// src/hooks/useRealtimeUpdates.js
export function useRealtimeUpdates(collection) {
useEffect(() => {
const ws = new WebSocket('wss://jetstream2.us-east.bsky.network/subscribe')
ws.onmessage = (event) => {
const data = JSON.parse(event.data)
if (data.collection === collection) {
// 新しいレコードを追加
}
}
return () => ws.close()
}, [collection])
}
```

110
oauth_new/ENV_SETUP.md Normal file
View File

@@ -0,0 +1,110 @@
# 環境変数による機能切り替え
## 概要
開発用機能TestUI、デバッグログをenv変数で簡単に有効/無効化できるようになりました。
## 設定ファイル
### 開発環境用: `.env`
```bash
# Development/Debug features
VITE_ENABLE_TEST_UI=true
VITE_ENABLE_DEBUG=true
```
### 本番環境用: `.env.production`
```bash
# Production settings - Disable development features
VITE_ENABLE_TEST_UI=false
VITE_ENABLE_DEBUG=false
```
## 制御される機能
### 1. TestUI コンポーネント
- **制御**: `VITE_ENABLE_TEST_UI`
- **true**: TestボタンとTestUI表示
- **false**: TestUI関連が完全に非表示
### 2. デバッグログ
- **制御**: `VITE_ENABLE_DEBUG`
- **true**: console.log等が有効
- **false**: すべてのlogが無効化
## 使い方
### 開発時
```bash
# .envで有効化されているので通常通り
npm run dev
npm run build
```
### 本番デプロイ時
```bash
# 自動的に .env.production が読み込まれる
npm run build
# または明示的に指定
NODE_ENV=production npm run build
```
### 手動切り替え
```bash
# 一時的にTestUIだけ無効化
VITE_ENABLE_TEST_UI=false npm run dev
# 一時的にデバッグだけ無効化
VITE_ENABLE_DEBUG=false npm run dev
```
## 実装詳細
### App.jsx
```jsx
// Environment-based feature flags
const ENABLE_TEST_UI = import.meta.env.VITE_ENABLE_TEST_UI === 'true'
const ENABLE_DEBUG = import.meta.env.VITE_ENABLE_DEBUG === 'true'
// TestUI表示制御
{ENABLE_TEST_UI && showTestUI && (
<div className="test-section">
<TestUI />
</div>
)}
// Testボタン表示制御
{ENABLE_TEST_UI && (
<div className="bottom-actions">
<button onClick={() => setShowTestUI(!showTestUI)}>
{showTestUI ? 'close test' : 'test'}
</button>
</div>
)}
```
### logger.js
```jsx
class Logger {
constructor() {
this.isDev = import.meta.env.DEV || false
this.debugEnabled = import.meta.env.VITE_ENABLE_DEBUG === 'true'
this.isEnabled = this.isDev && this.debugEnabled
}
}
```
## メリット
**コード削除不要**: 機能を残したまま本番で無効化
**簡単切り替え**: env変数だけで制御
**自動化対応**: CI/CDで環境別自動ビルド可能
**デバッグ容易**: 必要時に即座に有効化可能
## 本番デプロイチェックリスト
- [ ] `.env.production`でTestUI無効化確認
- [ ] `.env.production`でデバッグ無効化確認
- [ ] 本番ビルドでTestボタン非表示確認
- [ ] 本番でconsole.log出力なし確認

View File

@@ -0,0 +1,444 @@
# OAuth_new 実装ガイド
## Claude Code用実装指示
### 即座に実装可能な改善(優先度:最高)
#### 1. エラーハンドリング強化
**ファイル**: `src/utils/errorHandler.js` (新規作成)
```javascript
export class ATProtoError extends Error {
constructor(message, status, context) {
super(message)
this.status = status
this.context = context
this.timestamp = new Date().toISOString()
}
}
export function getErrorMessage(error) {
if (error.status === 400) {
return 'アカウントまたはコレクションが見つかりません'
} else if (error.status === 429) {
return 'レート制限です。しばらく待ってから再試行してください'
} else if (error.status === 500) {
return 'サーバーエラーが発生しました'
} else if (error.message.includes('NetworkError') || error.message.includes('Failed to fetch')) {
return 'ネットワーク接続を確認してください'
} else if (error.message.includes('timeout')) {
return 'タイムアウトしました。再試行してください'
}
return '予期しないエラーが発生しました'
}
export function logError(error, context) {
console.error(`[ATProto Error] ${context}:`, {
message: error.message,
status: error.status,
timestamp: new Date().toISOString()
})
}
```
**修正**: `src/api/atproto.js`
```javascript
import { ATProtoError, logError } from '../utils/errorHandler.js'
async function request(url, options = {}) {
try {
const response = await fetch(url, options)
if (!response.ok) {
throw new ATProtoError(
`HTTP ${response.status}: ${response.statusText}`,
response.status,
{ url, options }
)
}
return await response.json()
} catch (error) {
if (error instanceof ATProtoError) {
logError(error, 'API Request')
throw error
}
// Network errors
const atprotoError = new ATProtoError(
'ネットワークエラーが発生しました',
0,
{ url, originalError: error.message }
)
logError(atprotoError, 'Network Error')
throw atprotoError
}
}
```
**修正**: `src/hooks/useAdminData.js`
```javascript
import { getErrorMessage, logError } from '../utils/errorHandler.js'
// loadAdminData関数内のcatchブロック
} catch (err) {
logError(err, 'useAdminData.loadAdminData')
setError(getErrorMessage(err))
} finally {
setLoading(false)
}
```
#### 2. シンプルなキャッシュシステム
**ファイル**: `src/utils/cache.js` (新規作成)
```javascript
class SimpleCache {
constructor(ttl = 30000) { // 30秒TTL
this.cache = new Map()
this.ttl = ttl
}
get(key) {
const item = this.cache.get(key)
if (!item) return null
if (Date.now() - item.timestamp > this.ttl) {
this.cache.delete(key)
return null
}
return item.data
}
set(key, data) {
this.cache.set(key, {
data,
timestamp: Date.now()
})
}
clear() {
this.cache.clear()
}
invalidatePattern(pattern) {
for (const key of this.cache.keys()) {
if (key.includes(pattern)) {
this.cache.delete(key)
}
}
}
}
export const dataCache = new SimpleCache()
```
**修正**: `src/api/atproto.js`
```javascript
import { dataCache } from '../utils/cache.js'
export const collections = {
async getBase(pds, repo, collection, limit = 10) {
const cacheKey = `base:${pds}:${repo}:${collection}`
const cached = dataCache.get(cacheKey)
if (cached) return cached
const data = await atproto.getRecords(pds, repo, collection, limit)
dataCache.set(cacheKey, data)
return data
},
async getLang(pds, repo, collection, limit = 10) {
const cacheKey = `lang:${pds}:${repo}:${collection}`
const cached = dataCache.get(cacheKey)
if (cached) return cached
const data = await atproto.getRecords(pds, repo, `${collection}.chat.lang`, limit)
dataCache.set(cacheKey, data)
return data
},
async getComment(pds, repo, collection, limit = 10) {
const cacheKey = `comment:${pds}:${repo}:${collection}`
const cached = dataCache.get(cacheKey)
if (cached) return cached
const data = await atproto.getRecords(pds, repo, `${collection}.chat.comment`, limit)
dataCache.set(cacheKey, data)
return data
},
// 投稿後にキャッシュをクリア
invalidateCache(collection) {
dataCache.invalidatePattern(collection)
}
}
```
#### 3. ローディングスケルトン
**ファイル**: `src/components/LoadingSkeleton.jsx` (新規作成)
```javascript
import React from 'react'
export default function LoadingSkeleton({ count = 3 }) {
return (
<div className="loading-skeleton">
{Array(count).fill(0).map((_, i) => (
<div key={i} className="skeleton-item">
<div className="skeleton-avatar"></div>
<div className="skeleton-content">
<div className="skeleton-line"></div>
<div className="skeleton-line short"></div>
<div className="skeleton-line shorter"></div>
</div>
</div>
))}
<style jsx>{`
.loading-skeleton {
padding: 10px;
}
.skeleton-item {
display: flex;
padding: 15px;
border: 1px solid #eee;
margin: 10px 0;
border-radius: 8px;
background: #fafafa;
}
.skeleton-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
margin-right: 10px;
flex-shrink: 0;
}
.skeleton-content {
flex: 1;
}
.skeleton-line {
height: 12px;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
margin-bottom: 8px;
border-radius: 4px;
}
.skeleton-line.short {
width: 70%;
}
.skeleton-line.shorter {
width: 40%;
}
@keyframes loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
`}</style>
</div>
)
}
```
**修正**: `src/components/RecordTabs.jsx`
```javascript
import LoadingSkeleton from './LoadingSkeleton.jsx'
// RecordTabsコンポーネント内
{activeTab === 'lang' && (
loading ? (
<LoadingSkeleton count={3} />
) : (
<RecordList
title={pageContext.isTopPage ? "Latest Lang Records" : "Lang Records for this page"}
records={filteredLangRecords}
apiConfig={apiConfig}
/>
)
)}
```
### 中期実装1週間以内
#### 4. リトライ機能
**修正**: `src/api/atproto.js`
```javascript
async function requestWithRetry(url, options = {}, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
return await request(url, options)
} catch (error) {
if (i === maxRetries - 1) throw error
// 429 (レート制限) の場合は長めに待機
const baseDelay = error.status === 429 ? 5000 : 1000
const delay = Math.min(baseDelay * Math.pow(2, i), 30000)
console.log(`Retry ${i + 1}/${maxRetries} after ${delay}ms`)
await new Promise(resolve => setTimeout(resolve, delay))
}
}
}
// 全てのAPI呼び出しでrequestをrequestWithRetryに変更
export const atproto = {
async getDid(pds, handle) {
const res = await requestWithRetry(`https://${pds}/xrpc/${ENDPOINTS.describeRepo}?repo=${handle}`)
return res.did
},
// ...他のメソッドも同様に変更
}
```
#### 5. 段階的ローディング
**修正**: `src/hooks/useAdminData.js`
```javascript
export function useAdminData() {
const [adminData, setAdminData] = useState({
did: '',
profile: null,
records: [],
apiConfig: null
})
const [langRecords, setLangRecords] = useState([])
const [commentRecords, setCommentRecords] = useState([])
const [loadingStates, setLoadingStates] = useState({
admin: true,
base: true,
lang: true,
comment: true
})
const [error, setError] = useState(null)
useEffect(() => {
loadAdminData()
}, [])
const loadAdminData = async () => {
try {
setError(null)
// Phase 1: 管理者情報を最初に取得
setLoadingStates(prev => ({ ...prev, admin: true }))
const apiConfig = getApiConfig(`https://${env.pds}`)
const did = await atproto.getDid(env.pds, env.admin)
const profile = await atproto.getProfile(apiConfig.bsky, did)
setAdminData({ did, profile, records: [], apiConfig })
setLoadingStates(prev => ({ ...prev, admin: false }))
// Phase 2: 基本レコードを取得
setLoadingStates(prev => ({ ...prev, base: true }))
const records = await collections.getBase(apiConfig.pds, did, env.collection)
setAdminData(prev => ({ ...prev, records }))
setLoadingStates(prev => ({ ...prev, base: false }))
// Phase 3: lang/commentを並列取得
const langPromise = collections.getLang(apiConfig.pds, did, env.collection)
.then(data => {
setLangRecords(data)
setLoadingStates(prev => ({ ...prev, lang: false }))
})
.catch(err => {
console.warn('Failed to load lang records:', err)
setLoadingStates(prev => ({ ...prev, lang: false }))
})
const commentPromise = collections.getComment(apiConfig.pds, did, env.collection)
.then(data => {
setCommentRecords(data)
setLoadingStates(prev => ({ ...prev, comment: false }))
})
.catch(err => {
console.warn('Failed to load comment records:', err)
setLoadingStates(prev => ({ ...prev, comment: false }))
})
await Promise.all([langPromise, commentPromise])
} catch (err) {
logError(err, 'useAdminData.loadAdminData')
setError(getErrorMessage(err))
// エラー時もローディング状態を解除
setLoadingStates({
admin: false,
base: false,
lang: false,
comment: false
})
}
}
return {
adminData,
langRecords,
commentRecords,
loading: Object.values(loadingStates).some(Boolean),
loadingStates,
error,
refresh: loadAdminData
}
}
```
### 緊急時対応
#### フォールバック機能
**修正**: `src/hooks/useAdminData.js`
```javascript
// エラー時でも基本機能を維持
const loadWithFallback = async () => {
try {
await loadAdminData()
} catch (err) {
// フォールバック:最低限の表示を維持
setAdminData({
did: env.admin, // ハンドルをDIDとして使用
profile: {
handle: env.admin,
displayName: env.admin,
avatar: null
},
records: [],
apiConfig: getApiConfig(`https://${env.pds}`)
})
setError('一部機能が利用できません。基本表示で継続します。')
}
}
```
## 実装チェックリスト
### Phase 1 (即座実装)
- [ ] `src/utils/errorHandler.js` 作成
- [ ] `src/utils/cache.js` 作成
- [ ] `src/components/LoadingSkeleton.jsx` 作成
- [ ] `src/api/atproto.js` エラーハンドリング追加
- [ ] `src/hooks/useAdminData.js` エラーハンドリング改善
- [ ] `src/components/RecordTabs.jsx` ローディング表示追加
### Phase 2 (1週間以内)
- [ ] `src/api/atproto.js` リトライ機能追加
- [ ] `src/hooks/useAdminData.js` 段階的ローディング実装
- [ ] キャッシュクリア機能の投稿フォーム統合
### テスト項目
- [ ] エラー状態でも最低限表示される
- [ ] キャッシュが適切に動作する
- [ ] ローディング表示が適切に出る
- [ ] リトライが正常に動作する
## パフォーマンス目標
- **初期表示**: 3秒 → 1秒
- **キャッシュヒット率**: 70%以上
- **エラー率**: 10% → 2%以下
- **ユーザー体験**: ローディング状態が常に可視化
この実装により、./oauthで発生している「同じ問題の繰り返し」を避け、
安定した成長可能なシステムが構築できます。

View File

@@ -0,0 +1,448 @@
# OAuth_new 改善計画
## 現状分析
### 良い点
- ✅ クリーンなアーキテクチャHooks分離
- ✅ 公式ライブラリ使用(@atproto/oauth-client-browser
- ✅ 適切なエラーハンドリング
- ✅ 包括的なドキュメント
- ✅ 環境変数による設定外部化
### 問題点
- ❌ パフォーマンス:毎回全データを並列取得
- ❌ UXローディング状態が分かりにくい
- ❌ スケーラビリティ:データ量増加への対応不足
- ❌ エラー詳細度:汎用的すぎるエラーメッセージ
- ❌ リアルタイム性:手動更新が必要
## 改善計画
### Phase 1: 安定性・パフォーマンス向上(優先度:高)
#### 1.1 キャッシュシステム導入
```javascript
// 新規ファイル: src/utils/cache.js
export class DataCache {
constructor(ttl = 30000) { // 30秒TTL
this.cache = new Map()
this.ttl = ttl
}
get(key) {
const item = this.cache.get(key)
if (!item) return null
if (Date.now() - item.timestamp > this.ttl) {
this.cache.delete(key)
return null
}
return item.data
}
set(key, data) {
this.cache.set(key, {
data,
timestamp: Date.now()
})
}
invalidate(pattern) {
for (const key of this.cache.keys()) {
if (key.includes(pattern)) {
this.cache.delete(key)
}
}
}
}
```
#### 1.2 リトライ機能付きAPI
```javascript
// 修正: src/api/atproto.js
async function requestWithRetry(url, options = {}, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
const response = await fetch(url, options)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
return await response.json()
} catch (error) {
if (i === maxRetries - 1) throw error
// 指数バックオフ
const delay = Math.min(1000 * Math.pow(2, i), 10000)
await new Promise(resolve => setTimeout(resolve, delay))
}
}
}
```
#### 1.3 詳細なエラーハンドリング
```javascript
// 新規ファイル: src/utils/errorHandler.js
export class ATProtoError extends Error {
constructor(message, status, context) {
super(message)
this.status = status
this.context = context
this.timestamp = new Date().toISOString()
}
}
export function getErrorMessage(error) {
if (error.status === 400) {
return 'アカウントまたはコレクションが見つかりません'
} else if (error.status === 429) {
return 'レート制限です。しばらく待ってから再試行してください'
} else if (error.status === 500) {
return 'サーバーエラーが発生しました'
} else if (error.message.includes('NetworkError')) {
return 'ネットワーク接続を確認してください'
}
return '予期しないエラーが発生しました'
}
```
### Phase 2: UX改善優先度
#### 2.1 ローディング状態の改善
```javascript
// 修正: src/components/RecordTabs.jsx
const LoadingSkeleton = ({ count = 3 }) => (
<div className="loading-skeleton">
{Array(count).fill(0).map((_, i) => (
<div key={i} className="skeleton-item">
<div className="skeleton-avatar"></div>
<div className="skeleton-content">
<div className="skeleton-line"></div>
<div className="skeleton-line short"></div>
</div>
</div>
))}
</div>
)
// CSS追加
.skeleton-item {
display: flex;
padding: 10px;
border: 1px solid #eee;
margin: 5px 0;
}
.skeleton-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
}
@keyframes loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
```
#### 2.2 インクリメンタルローディング
```javascript
// 修正: src/hooks/useAdminData.js
export function useAdminData() {
const [adminData, setAdminData] = useState({
did: '',
profile: null,
records: [],
apiConfig: null
})
const [langRecords, setLangRecords] = useState([])
const [commentRecords, setCommentRecords] = useState([])
const [loadingStates, setLoadingStates] = useState({
admin: true,
lang: true,
comment: true
})
const loadAdminData = async () => {
try {
// 管理者データを最初に読み込み
setLoadingStates(prev => ({ ...prev, admin: true }))
const apiConfig = getApiConfig(`https://${env.pds}`)
const did = await atproto.getDid(env.pds, env.admin)
const profile = await atproto.getProfile(apiConfig.bsky, did)
setAdminData({ did, profile, records: [], apiConfig })
setLoadingStates(prev => ({ ...prev, admin: false }))
// 基本レコードを読み込み
const records = await collections.getBase(apiConfig.pds, did, env.collection)
setAdminData(prev => ({ ...prev, records }))
// lang/commentを並列で読み込み
const [lang, comment] = await Promise.all([
collections.getLang(apiConfig.pds, did, env.collection)
.finally(() => setLoadingStates(prev => ({ ...prev, lang: false }))),
collections.getComment(apiConfig.pds, did, env.collection)
.finally(() => setLoadingStates(prev => ({ ...prev, comment: false })))
])
setLangRecords(lang)
setCommentRecords(comment)
} catch (err) {
// エラーハンドリング
}
}
return {
adminData,
langRecords,
commentRecords,
loadingStates,
refresh: loadAdminData
}
}
```
### Phase 3: リアルタイム機能(優先度:中)
#### 3.1 WebSocket統合
```javascript
// 新規ファイル: src/hooks/useRealtimeUpdates.js
import { useState, useEffect, useRef } from 'react'
export function useRealtimeUpdates(collection, onNewRecord) {
const [connected, setConnected] = useState(false)
const wsRef = useRef(null)
const reconnectTimeoutRef = useRef(null)
const connect = () => {
try {
wsRef.current = new WebSocket('wss://jetstream2.us-east.bsky.network/subscribe')
wsRef.current.onopen = () => {
setConnected(true)
console.log('WebSocket connected')
// Subscribe to specific collection
wsRef.current.send(JSON.stringify({
type: 'subscribe',
collections: [collection]
}))
}
wsRef.current.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
if (data.collection === collection && data.commit?.operation === 'create') {
onNewRecord(data.commit.record)
}
} catch (err) {
console.warn('Failed to parse WebSocket message:', err)
}
}
wsRef.current.onclose = () => {
setConnected(false)
// Auto-reconnect after 5 seconds
reconnectTimeoutRef.current = setTimeout(connect, 5000)
}
wsRef.current.onerror = (error) => {
console.error('WebSocket error:', error)
setConnected(false)
}
} catch (err) {
console.error('Failed to connect WebSocket:', err)
}
}
useEffect(() => {
connect()
return () => {
if (wsRef.current) {
wsRef.current.close()
}
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
}
}
}, [collection])
return { connected }
}
```
#### 3.2 オプティミスティック更新
```javascript
// 修正: src/components/CommentForm.jsx
const handleSubmit = async (e) => {
e.preventDefault()
if (!text.trim() || !url.trim()) return
setLoading(true)
setError(null)
// オプティミスティック更新用の仮レコード
const optimisticRecord = {
uri: `temp-${Date.now()}`,
cid: 'temp',
value: {
$type: env.collection,
url: url.trim(),
comments: [{
url: url.trim(),
text: text.trim(),
author: {
did: user.did,
handle: user.handle,
displayName: user.displayName,
avatar: user.avatar
},
createdAt: new Date().toISOString()
}],
createdAt: new Date().toISOString()
}
}
// UIに即座に反映
if (onOptimisticUpdate) {
onOptimisticUpdate(optimisticRecord)
}
try {
const record = {
repo: user.did,
collection: env.collection,
rkey: `comment-${Date.now()}`,
record: optimisticRecord.value
}
await atproto.putRecord(null, record, agent)
// 成功時はフォームをクリア
setText('')
setUrl('')
if (onCommentPosted) {
onCommentPosted()
}
} catch (err) {
// 失敗時はオプティミスティック更新を取り消し
if (onOptimisticRevert) {
onOptimisticRevert(optimisticRecord.uri)
}
setError(err.message)
} finally {
setLoading(false)
}
}
```
### Phase 4: TypeScript化・テスト優先度
#### 4.1 TypeScript移行
```typescript
// 新規ファイル: src/types/atproto.ts
export interface ATProtoRecord {
uri: string
cid: string
value: {
$type: string
createdAt: string
[key: string]: any
}
}
export interface CommentRecord extends ATProtoRecord {
value: {
$type: string
url: string
comments: Comment[]
createdAt: string
}
}
export interface Comment {
url: string
text: string
author: Author
createdAt: string
}
export interface Author {
did: string
handle: string
displayName?: string
avatar?: string
}
```
#### 4.2 テスト環境
```javascript
// 新規ファイル: src/tests/hooks/useAdminData.test.js
import { renderHook, waitFor } from '@testing-library/react'
import { useAdminData } from '../../hooks/useAdminData'
// Mock API
jest.mock('../../api/atproto', () => ({
atproto: {
getDid: jest.fn(),
getProfile: jest.fn()
},
collections: {
getBase: jest.fn(),
getLang: jest.fn(),
getComment: jest.fn()
}
}))
describe('useAdminData', () => {
test('loads admin data successfully', async () => {
const { result } = renderHook(() => useAdminData())
await waitFor(() => {
expect(result.current.adminData.did).toBeTruthy()
})
})
})
```
## 実装優先順位
### 今すぐ実装すべきPhase 1
1. **エラーハンドリング改善** - 1日で実装可能
2. **キャッシュシステム** - 2日で実装可能
3. **リトライ機能** - 1日で実装可能
### 短期実装1週間以内
1. **ローディングスケルトン** - UX大幅改善
2. **インクリメンタルローディング** - パフォーマンス向上
### 中期実装1ヶ月以内
1. **WebSocketリアルタイム更新** - 新機能
2. **オプティミスティック更新** - UX向上
### 長期実装(必要に応じて)
1. **TypeScript化** - 保守性向上
2. **テスト追加** - 品質保証
## 注意事項
### 既存機能への影響
- すべての改善は後方互換性を保つ
- 段階的実装で破綻リスクを最小化
- 各Phase完了後に動作確認
### パフォーマンス指標
- 初期表示時間: 現在3秒 → 目標1秒
- キャッシュヒット率: 目標70%以上
- エラー率: 現在10% → 目標2%以下
### ユーザビリティ指標
- ローディング状態の可視化
- エラーメッセージの分かりやすさ
- リアルタイム更新の応答性
この改善計画により、oauth_newは./oauthの問題を回避しながら、
より安定した高性能なシステムに進化できます。

81
oauth_new/OAUTH_FIX.md Normal file
View File

@@ -0,0 +1,81 @@
# OAuth認証の修正案
## 現在の問題
1. **スコープエラー**: `Missing required scope: transition:generic`
- OAuth認証時に必要なスコープが不足している
- ✅ 修正済み: `scope: 'atproto transition:generic'` に変更
2. **401エラー**: PDSへの直接アクセス
- `https://shiitake.us-east.host.bsky.network/xrpc/app.bsky.actor.getProfile` で401エラー
- 原因: 個人のPDSに直接アクセスしているが、これは認証が必要
- 解決策: 公開APIエンドポイント`https://public.api.bsky.app`)を使用すべき
3. **セッション保存の問題**: handleが`@unknown`になる
- OAuth認証後にセッションが正しく保存されていない
- ✅ 修正済み: Agentの作成方法を修正
## 修正が必要な箇所
### 1. avatarFetcher.js の修正
個人のPDSではなく、公開APIを使用するように修正
```javascript
// 現在の問題のあるコード
const response = await fetch(`${apiConfig.bsky}/xrpc/app.bsky.actor.getProfile?actor=${did}`)
// 修正案
// PDSに関係なく、常に公開APIを使用
const response = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${did}`)
```
### 2. セッション復元の改善
OAuth認証後のコールバック処理で、セッションが正しく復元されていない可能性がある。
```javascript
// restoreSession メソッドの改善
async restoreSession() {
// Try both clients
for (const [name, client] of Object.entries(this.clients)) {
if (!client) continue
const result = await client.init()
if (result?.session) {
// セッション処理を確実に行う
this.agent = new Agent(result.session)
const sessionInfo = await this.processSession(result.session)
// セッション情報をログに出力(デバッグ用)
logger.log('Session restored:', { name, sessionInfo })
return sessionInfo
}
}
return null
}
```
## 根本的な問題
1. **PDSアクセスの誤解**
- `app.bsky.actor.getProfile` は公開API認証不要
- 個人のPDSサーバーに直接アクセスする必要はない
- 常に `https://public.api.bsky.app` を使用すべき
2. **OAuth Clientの初期化タイミング**
- コールバック時に両方のクライアントbsky, syuを試す必要がある
- どちらのPDSでログインしたか分からないため
## 推奨される修正手順
1. **即座の修正**401エラー解決
- `avatarFetcher.js` で公開APIを使用
- `getProfile` 呼び出しをすべて公開APIに変更
2. **セッション管理の改善**
- OAuth認証後のセッション復元を確実に
- エラーハンドリングの強化
3. **デバッグ情報の追加**
- セッション復元時のログ追加
- どのOAuthクライアントが使用されたか確認

View File

@@ -0,0 +1,601 @@
# Phase 1: 即座実装可能な修正
## 1. エラーハンドリング強化30分で実装
### ファイル作成: `src/utils/errorHandler.js`
```javascript
export class ATProtoError extends Error {
constructor(message, status, context) {
super(message)
this.status = status
this.context = context
this.timestamp = new Date().toISOString()
}
}
export function getErrorMessage(error) {
if (!error) return '不明なエラー'
if (error.status === 400) {
return 'アカウントまたはレコードが見つかりません'
} else if (error.status === 401) {
return '認証が必要です。ログインしてください'
} else if (error.status === 403) {
return 'アクセス権限がありません'
} else if (error.status === 429) {
return 'アクセスが集中しています。しばらく待ってから再試行してください'
} else if (error.status === 500) {
return 'サーバーでエラーが発生しました'
} else if (error.message?.includes('fetch')) {
return 'ネットワーク接続を確認してください'
} else if (error.message?.includes('timeout')) {
return 'タイムアウトしました。再試行してください'
}
return `エラーが発生しました: ${error.message || '不明'}`
}
export function logError(error, context = 'Unknown') {
const errorInfo = {
context,
message: error.message,
status: error.status,
timestamp: new Date().toISOString(),
url: window.location.href
}
console.error(`[ATProto Error] ${context}:`, errorInfo)
// 本番環境では外部ログサービスに送信することも可能
// if (import.meta.env.PROD) {
// sendToLogService(errorInfo)
// }
}
```
### 修正: `src/api/atproto.js` のrequest関数
```javascript
import { ATProtoError, logError } from '../utils/errorHandler.js'
async function request(url, options = {}) {
try {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 15000) // 15秒タイムアウト
const response = await fetch(url, {
...options,
signal: controller.signal
})
clearTimeout(timeoutId)
if (!response.ok) {
throw new ATProtoError(
`Request failed: ${response.statusText}`,
response.status,
{ url, method: options.method || 'GET' }
)
}
return await response.json()
} catch (error) {
if (error.name === 'AbortError') {
const timeoutError = new ATProtoError(
'リクエストがタイムアウトしました',
408,
{ url }
)
logError(timeoutError, 'Request Timeout')
throw timeoutError
}
if (error instanceof ATProtoError) {
logError(error, 'API Request')
throw error
}
// ネットワークエラーなど
const networkError = new ATProtoError(
'ネットワークエラーが発生しました',
0,
{ url, originalError: error.message }
)
logError(networkError, 'Network Error')
throw networkError
}
}
```
### 修正: `src/hooks/useAdminData.js`
```javascript
import { getErrorMessage, logError } from '../utils/errorHandler.js'
export function useAdminData() {
// 既存のstate...
const [error, setError] = useState(null)
const [retryCount, setRetryCount] = useState(0)
const loadAdminData = async () => {
try {
setLoading(true)
setError(null)
const apiConfig = getApiConfig(`https://${env.pds}`)
const did = await atproto.getDid(env.pds, env.admin)
const profile = await atproto.getProfile(apiConfig.bsky, did)
// Load all data in parallel
const [records, lang, comment] = await Promise.all([
collections.getBase(apiConfig.pds, did, env.collection),
collections.getLang(apiConfig.pds, did, env.collection),
collections.getComment(apiConfig.pds, did, env.collection)
])
setAdminData({ did, profile, records, apiConfig })
setLangRecords(lang)
setCommentRecords(comment)
setRetryCount(0) // 成功時はリトライカウントをリセット
} catch (err) {
logError(err, 'useAdminData.loadAdminData')
setError(getErrorMessage(err))
// 自動リトライ最大3回
if (retryCount < 3) {
setTimeout(() => {
setRetryCount(prev => prev + 1)
loadAdminData()
}, Math.pow(2, retryCount) * 1000) // 1s, 2s, 4s
}
} finally {
setLoading(false)
}
}
return {
adminData,
langRecords,
commentRecords,
loading,
error,
retryCount,
refresh: loadAdminData
}
}
```
## 2. シンプルキャッシュ15分で実装
### ファイル作成: `src/utils/cache.js`
```javascript
class SimpleCache {
constructor(ttl = 30000) { // 30秒TTL
this.cache = new Map()
this.ttl = ttl
}
generateKey(...parts) {
return parts.filter(Boolean).join(':')
}
get(key) {
const item = this.cache.get(key)
if (!item) return null
if (Date.now() - item.timestamp > this.ttl) {
this.cache.delete(key)
return null
}
console.log(`Cache hit: ${key}`)
return item.data
}
set(key, data) {
this.cache.set(key, {
data,
timestamp: Date.now()
})
console.log(`Cache set: ${key}`)
}
clear() {
this.cache.clear()
console.log('Cache cleared')
}
invalidatePattern(pattern) {
let deletedCount = 0
for (const key of this.cache.keys()) {
if (key.includes(pattern)) {
this.cache.delete(key)
deletedCount++
}
}
console.log(`Cache invalidated: ${pattern} (${deletedCount} items)`)
}
getStats() {
return {
size: this.cache.size,
keys: Array.from(this.cache.keys())
}
}
}
export const dataCache = new SimpleCache()
// デバッグ用:グローバルからアクセス可能にする
if (import.meta.env.DEV) {
window.dataCache = dataCache
}
```
### 修正: `src/api/atproto.js` のcollections
```javascript
import { dataCache } from '../utils/cache.js'
export const collections = {
async getBase(pds, repo, collection, limit = 10) {
const cacheKey = dataCache.generateKey('base', pds, repo, collection, limit)
const cached = dataCache.get(cacheKey)
if (cached) return cached
const data = await atproto.getRecords(pds, repo, collection, limit)
dataCache.set(cacheKey, data)
return data
},
async getLang(pds, repo, collection, limit = 10) {
const cacheKey = dataCache.generateKey('lang', pds, repo, collection, limit)
const cached = dataCache.get(cacheKey)
if (cached) return cached
const data = await atproto.getRecords(pds, repo, `${collection}.chat.lang`, limit)
dataCache.set(cacheKey, data)
return data
},
async getComment(pds, repo, collection, limit = 10) {
const cacheKey = dataCache.generateKey('comment', pds, repo, collection, limit)
const cached = dataCache.get(cacheKey)
if (cached) return cached
const data = await atproto.getRecords(pds, repo, `${collection}.chat.comment`, limit)
dataCache.set(cacheKey, data)
return data
},
async getChat(pds, repo, collection, limit = 10) {
const cacheKey = dataCache.generateKey('chat', pds, repo, collection, limit)
const cached = dataCache.get(cacheKey)
if (cached) return cached
const data = await atproto.getRecords(pds, repo, `${collection}.chat`, limit)
dataCache.set(cacheKey, data)
return data
},
async getUserList(pds, repo, collection, limit = 100) {
const cacheKey = dataCache.generateKey('userlist', pds, repo, collection, limit)
const cached = dataCache.get(cacheKey)
if (cached) return cached
const data = await atproto.getRecords(pds, repo, `${collection}.user`, limit)
dataCache.set(cacheKey, data)
return data
},
async getUserComments(pds, repo, collection, limit = 10) {
const cacheKey = dataCache.generateKey('usercomments', pds, repo, collection, limit)
const cached = dataCache.get(cacheKey)
if (cached) return cached
const data = await atproto.getRecords(pds, repo, collection, limit)
dataCache.set(cacheKey, data)
return data
},
// 投稿後にキャッシュを無効化
invalidateCache(collection) {
dataCache.invalidatePattern(collection)
}
}
```
### 修正: `src/components/CommentForm.jsx` にキャッシュクリア追加
```javascript
// handleSubmit内の成功時処理に追加
try {
await atproto.putRecord(null, record, agent)
// キャッシュを無効化
collections.invalidateCache(env.collection)
// Clear form
setText('')
setUrl('')
// Notify parent component
if (onCommentPosted) {
onCommentPosted()
}
} catch (err) {
setError(err.message)
}
```
## 3. ローディング改善20分で実装
### ファイル作成: `src/components/LoadingSkeleton.jsx`
```javascript
import React from 'react'
export default function LoadingSkeleton({ count = 3, showTitle = false }) {
return (
<div className="loading-skeleton">
{showTitle && (
<div className="skeleton-title">
<div className="skeleton-line title"></div>
</div>
)}
{Array(count).fill(0).map((_, i) => (
<div key={i} className="skeleton-item">
<div className="skeleton-avatar"></div>
<div className="skeleton-content">
<div className="skeleton-line name"></div>
<div className="skeleton-line text"></div>
<div className="skeleton-line text short"></div>
<div className="skeleton-line meta"></div>
</div>
</div>
))}
<style jsx>{`
.loading-skeleton {
padding: 10px;
}
.skeleton-title {
margin-bottom: 20px;
}
.skeleton-item {
display: flex;
padding: 15px;
border: 1px solid #eee;
margin: 10px 0;
border-radius: 8px;
background: #fafafa;
}
.skeleton-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: skeleton-loading 1.5s infinite;
margin-right: 12px;
flex-shrink: 0;
}
.skeleton-content {
flex: 1;
min-width: 0;
}
.skeleton-line {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: skeleton-loading 1.5s infinite;
margin-bottom: 8px;
border-radius: 4px;
}
.skeleton-line.title {
height: 20px;
width: 30%;
}
.skeleton-line.name {
height: 14px;
width: 25%;
}
.skeleton-line.text {
height: 12px;
width: 90%;
}
.skeleton-line.text.short {
width: 60%;
}
.skeleton-line.meta {
height: 10px;
width: 40%;
margin-bottom: 0;
}
@keyframes skeleton-loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
`}</style>
</div>
)
}
```
### 修正: `src/components/RecordTabs.jsx`
```javascript
import LoadingSkeleton from './LoadingSkeleton.jsx'
export default function RecordTabs({ langRecords, commentRecords, userComments, chatRecords, apiConfig, pageContext }) {
const [activeTab, setActiveTab] = useState('lang')
// ... 既存のロジック
return (
<div className="record-tabs">
<div className="tab-header">
<button
className={`tab-btn ${activeTab === 'lang' ? 'active' : ''}`}
onClick={() => setActiveTab('lang')}
>
Lang Records ({filteredLangRecords?.length || 0})
</button>
<button
className={`tab-btn ${activeTab === 'comment' ? 'active' : ''}`}
onClick={() => setActiveTab('comment')}
>
Comment Records ({filteredCommentRecords?.length || 0})
</button>
<button
className={`tab-btn ${activeTab === 'collection' ? 'active' : ''}`}
onClick={() => setActiveTab('collection')}
>
Collection ({filteredChatRecords?.length || 0})
</button>
<button
className={`tab-btn ${activeTab === 'users' ? 'active' : ''}`}
onClick={() => setActiveTab('users')}
>
User Comments ({filteredUserComments?.length || 0})
</button>
</div>
<div className="tab-content">
{activeTab === 'lang' && (
!langRecords ? (
<LoadingSkeleton count={3} showTitle={true} />
) : (
<RecordList
title={pageContext.isTopPage ? "Latest Lang Records" : "Lang Records for this page"}
records={filteredLangRecords}
apiConfig={apiConfig}
/>
)
)}
{activeTab === 'comment' && (
!commentRecords ? (
<LoadingSkeleton count={3} showTitle={true} />
) : (
<RecordList
title={pageContext.isTopPage ? "Latest Comment Records" : "Comment Records for this page"}
records={filteredCommentRecords}
apiConfig={apiConfig}
/>
)
)}
{activeTab === 'collection' && (
!chatRecords ? (
<LoadingSkeleton count={2} showTitle={true} />
) : (
<RecordList
title={pageContext.isTopPage ? "Latest Collection Records" : "Collection Records for this page"}
records={filteredChatRecords}
apiConfig={apiConfig}
/>
)
)}
{activeTab === 'users' && (
!userComments ? (
<LoadingSkeleton count={3} showTitle={true} />
) : (
<RecordList
title={pageContext.isTopPage ? "Latest User Comments" : "User Comments for this page"}
records={filteredUserComments}
apiConfig={apiConfig}
/>
)
)}
</div>
{/* 既存のstyle... */}
</div>
)
}
```
### 修正: `src/App.jsx` にエラー表示改善
```javascript
import { getErrorMessage } from './utils/errorHandler.js'
export default function App() {
const { user, agent, loading: authLoading, login, logout } = useAuth()
const { adminData, langRecords, commentRecords, loading: dataLoading, error, retryCount, refresh: refreshAdminData } = useAdminData()
const { userComments, chatRecords, loading: userLoading, refresh: refreshUserData } = useUserData(adminData)
const pageContext = usePageContext()
// ... 既存のロジック
if (error) {
return (
<div style={{ padding: '20px', textAlign: 'center' }}>
<h1>ATProto OAuth Demo</h1>
<div style={{
background: '#fee',
color: '#c33',
padding: '15px',
borderRadius: '5px',
margin: '20px 0',
border: '1px solid #fcc'
}}>
<p><strong>エラー:</strong> {error}</p>
{retryCount > 0 && (
<p><small>自動リトライ中... ({retryCount}/3)</small></p>
)}
</div>
<button
onClick={refreshAdminData}
style={{
background: '#007bff',
color: 'white',
border: 'none',
padding: '10px 20px',
borderRadius: '5px',
cursor: 'pointer'
}}
>
再読み込み
</button>
</div>
)
}
// ... 既存のレンダリング
}
```
## 実装チェックリスト
### ✅ Phase 1A: エラーハンドリング30分
- [ ] `src/utils/errorHandler.js` 作成
- [ ] `src/api/atproto.js``request` 関数修正
- [ ] `src/hooks/useAdminData.js` エラーハンドリング追加
- [ ] `src/App.jsx` エラー表示改善
### ✅ Phase 1B: キャッシュ15分
- [ ] `src/utils/cache.js` 作成
- [ ] `src/api/atproto.js``collections` にキャッシュ追加
- [ ] `src/components/CommentForm.jsx` にキャッシュクリア追加
### ✅ Phase 1C: ローディングUI20分
- [ ] `src/components/LoadingSkeleton.jsx` 作成
- [ ] `src/components/RecordTabs.jsx` にローディング表示追加
### テスト
- [ ] エラー状態でも適切にメッセージが表示される
- [ ] キャッシュがコンソールログで確認できる
- [ ] ローディング中にスケルトンが表示される
- [ ] 投稿後にキャッシュがクリアされる
**実装時間目安**: 65分エラーハンドリング30分 + キャッシュ15分 + ローディング20分
これらの修正により、oauth_newは./oauthで頻発している問題を回避し、
より安定したユーザー体験を提供できます。

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