89 Commits

Author SHA1 Message Date
f1e76ab31f fix post 2025-07-27 05:04:01 +09:00
3c9ef78696 add binary 2025-07-26 20:54:23 +09:00
ee2d21b0f3 update 2025-07-26 20:00:16 +09:00
0667ac58fb test game 2025-07-26 19:51:55 +09:00
d89855338b fix css 2025-07-18 10:57:42 +09:00
e19170cdff add pds.html 2025-07-18 00:05:04 +09:00
c3e22611f5 fix layout 2025-07-17 23:57:08 +09:00
2943c94ec1 binary 2025-07-17 22:23:14 +09:00
f27997b7e8 rm pds asset 2025-07-17 22:20:25 +09:00
447e4bded9 update 2025-07-17 22:12:06 +09:00
03161a52ca fix oauth-ai-chat 2025-07-17 19:26:40 +09:00
fe9381a860 fix blog post 2025-07-17 19:26:40 +09:00
f0cea89005 fix oauth filter 2025-07-16 22:57:09 +09:00
b059fe1de0 fix comment, rm console.log 2025-07-16 22:53:01 +09:00
07b0b0f702 fix css 2025-07-16 20:58:42 +09:00
ecd69557fe oauth markdown 2025-07-16 20:42:50 +09:00
452a0fda6a fix blog post 2025-07-16 11:47:15 +09:00
a62dd82790 fix config 2025-07-16 11:27:37 +09:00
3faec33bac fix blog post 2025-07-16 11:04:50 +09:00
33402f4a21 add blog post 2025-07-16 11:04:02 +09:00
3e65bc8210 binary 2025-07-16 10:18:03 +09:00
16d724ec25 update 2025-07-16 10:08:43 +09:00
69182a1bf8 update 2025-07-16 09:33:46 +09:00
0110773592 test ai-blog 2025-07-16 09:32:45 +09:00
75f108e7b8 fix blog post link 2025-07-14 15:27:10 +09:00
263189ce72 add blog post 2025-07-14 14:11:55 +09:00
7800a655f3 fix profile 2025-07-13 08:12:40 +09:00
76c797e4d8 add blog post 2025-07-13 07:52:43 +09:00
d1a1c92842 update binary 2025-07-11 13:38:22 +09:00
9da1f87640 fix update version 2025-07-11 13:09:15 +09:00
ddfc43512c add md msg 2025-07-11 08:52:34 +09:00
b3ccd61935 add my-blog msg 2025-07-11 08:51:46 +09:00
a243b6a44e fix post filename 2025-07-05 15:42:36 +09:00
e3c1cf4790 fix build err 2025-07-05 15:31:04 +09:00
a6236661bf post 2025-07-05 15:30:55 +09:00
195a4474c9 fix config.toml 2025-07-01 21:22:48 +09:00
4a34a6ca59 rm my-blog/oauth 2025-07-01 21:20:26 +09:00
4d01fb8507 fix oauth network err 2025-07-01 19:48:49 +09:00
d69c9aa09b update binary 2025-07-01 06:22:15 +09:00
99ee49f76e feat: add server-side image comparison shortcode support
- Add {{< img-compare >}} and [img-compare] shortcode syntax
- Implement server-side shortcode processing in Rust
- Create dedicated shortcode module for extensibility
- Fix image comparison slider display issues
- Remove caption display for cleaner UI
- Update to version 0.2.6

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-01 06:09:39 +09:00
19c0e28668 add post 2025-07-01 06:02:31 +09:00
bc99eb0814 update img-slider 2025-07-01 06:02:25 +09:00
cf93721bad fix social-app uri 2025-06-26 19:56:13 +09:00
8a8a121f4a fix delete record 2025-06-25 23:14:27 +09:00
be2bcae1d6 fix test ask-AI oauth profile 2025-06-25 23:03:50 +09:00
2c08a4acfb test blog profile 2025-06-25 21:18:13 +09:00
7791399314 fix claude-code proxy 2025-06-24 22:55:16 +09:00
26b1b2cf87 fix mobile css 2025-06-22 01:50:49 +09:00
7eb653f569 fix layout article.article-content 2025-06-22 01:16:59 +09:00
0fc920c844 fix layout 2025-06-22 00:35:54 +09:00
13c05d97d2 add claude-code-mcp-server 2025-06-22 00:26:20 +09:00
71acd44810 fix layout font-size 2025-06-22 00:25:44 +09:00
1b4579d0f1 fix layout font-size 2025-06-22 00:25:04 +09:00
09100f6d99 fix ask-ai prompt userdata 2025-06-22 00:01:27 +09:00
169de9064a fix link github 2025-06-21 19:11:01 +09:00
097c794623 fix oauth bsky button 2025-06-21 18:30:39 +09:00
b652e01dd3 fix oauth loading button 2025-06-21 17:03:23 +09:00
31af524303 fix layout 2025-06-21 15:46:21 +09:00
6be024864d cleanup docs 2025-06-21 00:07:22 +09:00
eef1fdad38 fix layout 2025-06-20 23:26:32 +09:00
b7e411e8b2 add img 2025-06-20 23:26:11 +09:00
8f9d803a94 fix gh-actions 2025-06-20 00:08:23 +09:00
f9b9c2ab52 fix layout 2025-06-20 00:00:51 +09:00
210ce801f1 update binary 2025-06-20 00:00:29 +09:00
6cb46f2ca1 fix token refresh 2025-06-19 23:01:41 +09:00
9406597b82 add post 2025-06-19 22:08:43 +09:00
0dbc3ba67e fix html text 2025-06-19 21:22:01 +09:00
a7e6fc4a1a Release v0.2.4: Complete OAuth system with AI chat and mobile support
- Fixed OAuth authentication with ATProto integration
- Implemented Ask AI functionality with conversation history
- Resolved PDS/web link issues for cross-network compatibility
- Added comprehensive mobile responsive design
- Enhanced comment posting with loading states and auto-refresh
- Improved chat record display with question/answer pairing
- Fixed tab scrolling and layout overflow issues

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-19 20:02:24 +09:00
3adcfdacf5 fix post commnet 2025-06-19 19:58:40 +09:00
004081337c fix ask-ai put 2025-06-19 19:52:31 +09:00
5ce0e0fd7a fix ask-ai 2025-06-19 19:18:50 +09:00
f816abb84f fix mobile css, ask-ai 2025-06-19 19:12:29 +09:00
8541af9293 add binary 2025-06-19 17:26:48 +09:00
68b49d5aaf Fix jetstream monitoring for ai.syui.log collections
- Fixed JetstreamMessage struct to correctly parse collection from commit object
- Fixed user list JSON format to match oauth app expectations (removed metadata field)
- Added monitoring for both ai.syui.log and ai.syui.log.chat.comment collections
- Improved error handling and debug output for stream processing
- Jetstream auto user registration now working correctly

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-19 17:14:11 +09:00
53dab3fd09 fix ailog stream server 2025-06-19 17:13:02 +09:00
5fac689f98 fix hugo callback 2025-06-19 17:13:02 +09:00
293421b7a5 add callback test 2025-06-19 14:47:25 +09:00
1793de40c1 fix rm callback page 2025-06-19 13:17:32 +09:00
30bdd7b633 fix oauth package name 2025-06-19 13:09:37 +09:00
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
174 changed files with 13377 additions and 9540 deletions
.claude
.gitea/workflows
.github/workflows
.gitignoreCargo.toml
bin
my-blog
oauth
oauth_new
pds
scpt
src

@@ -1,59 +0,0 @@
{
"permissions": {
"allow": [
"Bash(cargo init:*)",
"Bash(cargo:*)",
"Bash(find:*)",
"Bash(mkdir:*)",
"Bash(../target/debug/ailog new:*)",
"Bash(../target/debug/ailog build)",
"Bash(/Users/syui/ai/log/target/debug/ailog build)",
"Bash(ls:*)",
"Bash(curl:*)",
"Bash(pkill:*)",
"WebFetch(domain:docs.anthropic.com)",
"WebFetch(domain:github.com)",
"Bash(rm:*)",
"Bash(mv:*)",
"Bash(cp:*)",
"Bash(timeout:*)",
"Bash(grep:*)",
"Bash(./target/debug/ailog:*)",
"Bash(cat:*)",
"Bash(npm install)",
"Bash(npm run build:*)",
"Bash(chmod:*)",
"Bash(./scripts/tunnel.sh:*)",
"Bash(PRODUCTION=true cargo run -- build)",
"Bash(cloudflared tunnel:*)",
"Bash(npm install:*)",
"Bash(./scripts/build-oauth-partial.zsh:*)",
"Bash(./scripts/quick-oauth-update.zsh:*)",
"Bash(../target/debug/ailog serve)",
"Bash(./scripts/test-oauth.sh:*)",
"Bash(./run.zsh:*)",
"Bash(npm run dev:*)",
"Bash(./target/release/ailog:*)",
"Bash(rg:*)",
"Bash(../target/release/ailog build)",
"Bash(zsh run.zsh:*)",
"Bash(hugo:*)",
"WebFetch(domain:docs.bsky.app)",
"WebFetch(domain:syui.ai)",
"Bash(rustup target list:*)",
"Bash(rustup target:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git push:*)",
"Bash(git tag:*)",
"Bash(../bin/ailog:*)",
"Bash(../target/release/ailog oauth build:*)",
"Bash(ailog:*)",
"WebFetch(domain:plc.directory)",
"WebFetch(domain:atproto.com)",
"WebFetch(domain:syu.is)",
"Bash(sed:*)"
],
"deny": []
}
}

@@ -0,0 +1,123 @@
name: Deploy to Cloudflare Pages
on:
push:
branches:
- main
workflow_dispatch:
env:
OAUTH_DIR: oauth
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: '21'
- name: Install dependencies
run: |
cd ${{ env.OAUTH_DIR }}
npm install
- name: Build OAuth app
run: |
cd ${{ env.OAUTH_DIR }}
NODE_ENV=production npm run build
- name: Copy OAuth build to static
run: |
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: Cache ailog binary
uses: actions/cache@v4
with:
path: ./bin
key: ailog-bin-${{ runner.os }}
restore-keys: |
ailog-bin-${{ runner.os }}
- name: Setup ailog binary
run: |
# Get expected version from Cargo.toml
EXPECTED_VERSION=$(grep '^version' Cargo.toml | cut -d'"' -f2)
echo "Expected version from Cargo.toml: $EXPECTED_VERSION"
# Check current binary version if exists
if [ -f "./bin/ailog" ]; then
CURRENT_VERSION=$(./bin/ailog --version 2>/dev/null || echo "unknown")
echo "Current binary version: $CURRENT_VERSION"
else
CURRENT_VERSION="none"
echo "No binary found"
fi
# Check OS
OS="${{ runner.os }}"
echo "Runner OS: $OS"
# Use pre-packaged binary if version matches or extract from tar.gz
if [ "$CURRENT_VERSION" = "$EXPECTED_VERSION" ]; then
echo "Binary is up to date"
chmod +x ./bin/ailog
elif [ "$OS" = "Linux" ] && [ -f "./bin/ailog-linux-x86_64.tar.gz" ]; then
echo "Extracting ailog from pre-packaged tar.gz..."
cd bin
tar -xzf ailog-linux-x86_64.tar.gz
chmod +x ailog
cd ..
# Verify extracted version
EXTRACTED_VERSION=$(./bin/ailog --version 2>/dev/null || echo "unknown")
echo "Extracted binary version: $EXTRACTED_VERSION"
if [ "$EXTRACTED_VERSION" != "$EXPECTED_VERSION" ]; then
echo "Warning: Binary version mismatch. Expected $EXPECTED_VERSION but got $EXTRACTED_VERSION"
fi
else
echo "Error: No suitable binary found for OS: $OS"
exit 1
fi
- name: Build site with ailog
run: |
cd my-blog
../bin/ailog build
- name: List public directory
run: |
ls -la my-blog/public/
- name: Deploy to Cloudflare Pages
uses: cloudflare/pages-action@v1
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
projectName: ${{ secrets.CLOUDFLARE_PROJECT_NAME }}
directory: my-blog/public
wranglerVersion: '3'
cleanup:
needs: deploy
runs-on: ubuntu-latest
if: success()
steps:
- name: Cleanup old deployments
run: |
curl -X PATCH \
"https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${{ secrets.CLOUDFLARE_PROJECT_NAME }}" \
-H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \
-H "Content-Type: application/json" \
-d "{ \"deployment_configs\": { \"production\": { \"deployment_retention\": ${{ env.KEEP_DEPLOYMENTS }} } } }"

@@ -0,0 +1,193 @@
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 with Gitea API
run: |
# Prepare release files
mkdir -p release
find artifacts -name "*.tar.gz" -exec cp {} release/ \;
# Create release via Gitea API
RELEASE_RESPONSE=$(curl -X POST \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases" \
-H "Authorization: token ${{ github.token }}" \
-H "Content-Type: application/json" \
-d '{
"tag_name": "${{ steps.tag_name.outputs.tag }}",
"name": "ailog ${{ steps.tag_name.outputs.tag }}",
"body": "'"$(cat release_notes.md | sed 's/"/\\"/g' | tr '\n' ' ')"'",
"draft": false,
"prerelease": '"$(if echo "${{ steps.tag_name.outputs.tag }}" | grep -E "(alpha|beta|rc)"; then echo "true"; else echo "false"; fi)"'
}')
# Get release ID
RELEASE_ID=$(echo "$RELEASE_RESPONSE" | jq -r '.id')
echo "Created release with ID: $RELEASE_ID"
# Upload release assets
for file in release/*.tar.gz; do
if [ -f "$file" ]; then
filename=$(basename "$file")
echo "Uploading $filename..."
curl -X POST \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/$RELEASE_ID/assets?name=$filename" \
-H "Authorization: token ${{ github.token }}" \
-H "Content-Type: application/octet-stream" \
--data-binary @"$file"
fi
done

@@ -6,6 +6,10 @@ on:
- main
workflow_dispatch:
env:
OAUTH_DIR: oauth
KEEP_DEPLOYMENTS: 5
jobs:
deploy:
runs-on: ubuntu-latest
@@ -24,22 +28,29 @@ 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: |
# Remove old assets (following run.zsh pattern)
rm -rf my-blog/static/assets
# Copy all dist files to static
cp -rf oauth/dist/* my-blog/static/
# Copy index.html to oauth-assets.html template
cp oauth/dist/index.html my-blog/templates/oauth-assets.html
cp -rf ${{ env.OAUTH_DIR }}/dist/* my-blog/static/
cp ${{ env.OAUTH_DIR }}/dist/index.html my-blog/templates/oauth-assets.html
- name: Build PDS app
run: |
cd pds
npm install
npm run build
- name: Copy PDS build to static
run: |
rm -rf my-blog/static/pds
cp -rf pds/dist my-blog/static/pds
- name: Cache ailog binary
uses: actions/cache@v4
@@ -109,3 +120,50 @@ jobs:
directory: my-blog/public
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
wranglerVersion: '3'
cleanup:
needs: deploy
runs-on: ubuntu-latest
if: success()
steps:
- name: Cleanup old deployments
run: |
curl -X PATCH \
"https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${{ secrets.CLOUDFLARE_PROJECT_NAME }}" \
-H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \
-H "Content-Type: application/json" \
-d "{ \"deployment_configs\": { \"production\": { \"deployment_retention\": ${{ env.KEEP_DEPLOYMENTS }} } } }"
# 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!"

11
.gitignore vendored

@@ -10,11 +10,18 @@ dist
node_modules
package-lock.json
my-blog/static/assets/comment-atproto-*
my-blog/static/ai-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
repos
oauth_old
oauth_example
my-blog/static/oauth/assets/comment-atproto*
*.lock
my-blog/config.toml
.claude/settings.local.json
my-blog/static/pds

@@ -1,6 +1,6 @@
[package]
name = "ailog"
version = "0.2.1"
version = "0.3.1"
edition = "2021"
authors = ["syui"]
description = "A static blog generator with AI features"
@@ -39,6 +39,8 @@ urlencoding = "2.1"
axum = "0.7"
tower = "0.5"
tower-http = { version = "0.5", features = ["cors", "fs"] }
axum-extra = { version = "0.9", features = ["typed-header"] }
tracing = "0.1"
hyper = { version = "1.0", features = ["full"] }
tower-sessions = "0.12"
jsonwebtoken = "9.2"
@@ -54,6 +56,8 @@ tokio-tungstenite = { version = "0.21", features = ["rustls-tls-webpki-roots", "
futures-util = "0.3"
tungstenite = { version = "0.21", features = ["rustls-tls-webpki-roots"], default-features = false }
rpassword = "7.3"
rustyline = "14.0"
dirs = "5.0"
[dev-dependencies]
tempfile = "3.14"
@@ -82,4 +86,4 @@ codegen-units = 1
[profile.dev.package."*"]
# Optimize dependencies in dev builds
opt-level = 3
opt-level = 3

Binary file not shown.

@@ -16,13 +16,76 @@ auto_translate = false
comment_moderation = false
ask_ai = true
provider = "ollama"
model = "qwen3"
model_translation = "llama3.2:1b"
model_technical = "phi3:mini"
host = "http://localhost:11434"
model = "gemma3"
host = "localhost:11434"
system_prompt = "あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"
handle = "ai.syui.ai"
#num_predict = 200
[ai.profiles]
[ai.profiles.user]
did = "did:plc:vzsvtbtbnwn22xjqhcu3vd6y"
handle = "syui.syui.ai"
display_name = "syui"
avatar_url = "https://bsky.syu.is/img/avatar/plain/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/bafkreif62mqyra4ndv6ohlscl7adp3vhalcjxwhs676ktfj2sq2drs3pdi@jpeg"
profile_url = "https://syu.is/profile/did:plc:vzsvtbtbnwn22xjqhcu3vd6y"
[ai.profiles.ai]
did = "did:plc:6qyecktefllvenje24fcxnie"
handle = "ai.syui.ai"
display_name = "ai"
avatar_url = "https://bsky.syu.is/img/avatar/plain/did:plc:6qyecktefllvenje24fcxnie/bafkreigo3ucp32carhbn3chfc3hlf6i7f4rplojc76iylihzpifyexi24y@jpeg"
profile_url = "https://syu.is/profile/did:plc:6qyecktefllvenje24fcxnie"
[ai.templates]
fallback = """なるほど!面白い話題だね!
{question}
アイが思うに、この手の技術って急速に進歩してるから、具体的な製品名とか実例を交えて話した方が分かりやすいかもしれないの!
最近だと、AI関連のツールやプロトコルがかなり充実してきてて、実用レベルのものが増えてるんだよ
アイは宇宙とかAIとか、難しい話も知ってるから、特にどんな角度から深掘りしたいの実装面それとも将来的な可能性とかアイと一緒に考えよう"""
[[ai.templates.responses]]
keywords = ["ゲーム", "game", "npc", "NPC"]
priority = 1
template = """わあゲームの話だねアイ、ゲームのAIってすっごく面白いと思う
{question}
アイが知ってることだと、最近のゲームはNPCがお話できるようになってるんだって**Inworld AI**っていうのがUE5で使えるようになってるし、**Unity Muse**も{current_year}年から本格的に始まってるんだよ!
アイが特に面白いと思うのは、**MCP**っていうのを使うと:
- GitHub MCPでゲームのファイル管理ができる
- Weather MCPでリアルタイムのお天気が連動する
- Slack MCPでチーム開発が効率化される
スタンフォードの研究では、ChatGPTベースのAI住民が自分で街を作って生活してるのを見たことがあるの数年後にはNPCの概念が根本的に変わりそうで、わくわくしちゃう
UE5への統合、どんな機能から試したいのアイも一緒に考えたい"""
[[ai.templates.responses]]
keywords = ["AI", "ai", "MCP", "mcp"]
priority = 1
template = """AIとMCPの話アイの得意分野だよ
{question}
{current_year}年の状況だと、MCP市場が拡大してて、実用的なサーバーが数多く使えるようになってるの
アイが知ってる開発系では:
- **GitHub MCP**: PR作成とリポジトリ管理が自動化
- **Docker MCP**: コンテナ操作をAIが代行
- **PostgreSQL MCP**: データベース設計・最適化を支援
クリエイティブ系では:
- **Blender MCP**: 3Dモデリングの自動化
- **Figma MCP**: デザインからコード変換
**Zapier MCP**なんて数千のアプリと連携できるから、もう手作業でやってる場合じゃないよね!
アイは小さい物質のことも知ってるから、どの分野でのMCP活用を考えてるのか教えて具体的なユースケースがあると、もっと詳しくお話できるよ"""
[oauth]
json = "client-metadata.json"
@@ -31,3 +94,30 @@ admin = "ai.syui.ai"
collection = "ai.syui.log"
pds = "syu.is"
handle_list = ["syui.syui.ai", "ai.syui.ai", "ai.ai"]
[blog]
base_url = "https://syui.ai"
content_dir = "./my-blog/content/posts"
[profiles]
[profiles.user]
handle = "syui.syui.ai"
did = "did:plc:vzsvtbtbnwn22xjqhcu3vd6y"
display_name = "syui"
avatar_url = "https://bsky.syu.is/img/avatar/plain/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/bafkreif62mqyra4ndv6ohlscl7adp3vhalcjxwhs676ktfj2sq2drs3pdi@jpeg"
profile_url = "https://syu.is/profile/did:plc:vzsvtbtbnwn22xjqhcu3vd6y"
[profiles.ai]
handle = "ai.syui.ai"
did = "did:plc:6qyecktefllvenje24fcxnie"
display_name = "ai"
avatar_url = "https://bsky.syu.is/img/avatar/plain/did:plc:6qyecktefllvenje24fcxnie/bafkreigo3ucp32carhbn3chfc3hlf6i7f4rplojc76iylihzpifyexi24y@jpeg"
profile_url = "https://syu.is/profile/did:plc:6qyecktefllvenje24fcxnie"
[paths]
claude_paths = [
"/Users/syui/.claude/local/claude",
"claude",
"/usr/local/bin/claude",
"/opt/homebrew/bin/claude"
]

@@ -155,3 +155,21 @@ fn main() {
console.log("Hello, world!");
```
## msg
[msg type="info" content="これは情報メッセージです。重要な情報を読者に伝えるために使用します。"]
{{< msg type="warning" content="これは警告メッセージです。注意が必要な情報を示します。" >}}
[msg type="error" content="これはエラーメッセージです。問題やエラーを示します。"]
{{< msg type="success" content="これは成功メッセージです。操作が成功したことを示します。" >}}
[msg type="note" content="これはノートメッセージです。補足情報や備考を示します。"]
[msg content="これはデフォルトメッセージです。タイプが指定されていない場合、自動的に情報メッセージとして表示されます。"]
## img-compare
[img-compare before="/img/ue_blender_model_ai_v0401.png" after="/img/ue_blender_model_ai_v0501.png" width="800" height="300"]

@@ -0,0 +1,78 @@
---
title: "oauthに対応した"
slug: "oauth"
date: 2025-06-19
tags: ["atproto"]
draft: false
---
現在、[syu.is](https://syu.is)に[atproto](https://github.com/bluesky-social/atproto)をselfhostしています。
oauthを`bsky.social`, `syu.is`ともに動くようにしました。
![](/img/atproto_oauth_syuis.png)
ここでいうselfhostは、pds, plc, bsky, bgsなどを自前のserverで動かし、連携することをいいいます。
ちなみに、atprotoは[bluesky](https://bsky.app)のようなものです。
ただし、その内容は結構複雑で、`at://did`の仕組みで動くsnsです。
usernameは`handle`という`domain`の形を採用しています。
didの名前解決(dns)をしているのが`plc`です。`pds`はuserのdataを保存しています。timelineに配信したり表示しているのが`bsky(appview)`, 統合しているのが`bgs`です。
その他、`social-app`がclientで、`ozone`がmoderationです。
```sh
"6qyecktefllvenje24fcxnie" -> "ai.syu.is"
```
## oauthでハマったところ
現在、`bsky.team`のpds, plc, bskyには`did:plc:6qyecktefllvenje24fcxnie`が登録されています。これは`syu.is``@ai.syui.ai`のアカウントです。
```sh
$ did=did:plc:6qyecktefllvenje24fcxnie
$ curl -sL https://plc.syu.is/$did|jq .alsoKnownAs
[ "at://ai.syui.ai" ]
$ curl -sL https://plc.directory/$did|jq .alsoKnownAs
[ "at://ai.syu.is" ]
```
しかし、みて分かる通り、bskyではhandle-changeが反映されていますが、pds, plcは`@ai.syu.is`で登録されており、更新されていないようです。
```sh
$ handle=ai.syui.ai
$ curl -sL "https://syu.is/xrpc/com.atproto.identity.resolveHandle?handle=$handle" | jq -r .did
did:plc:6qyecktefllvenje24fcxnie
$ curl -sL "https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=$handle" | jq -r .did
null
$ curl -sL "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=$handle" | jq -r .did
did:plc:6qyecktefllvenje24fcxnie
```
[msg type="warning" content="現在はbsky.teamのpdsにhandle-changeが反映されています。"]
oauthは、そのままではbsky.teamのpds, plcを使って名前解決を行います。この場合、まず、それらのserverにdidが登録されている必要があります。
次に、handleの更新が反映されている必要があります。もし反映されていない場合、handleとpasswordが一致しません。
localhostではhandleをdidにすることで突破できそうでしたが、本番環境では難しそうでした。
なお、[@atproto/oauth-provider](https://github.com/bluesky-social/atproto/tree/main/packages/oauth/oauth-provider)の本体を書き換えて、pdsで使うと回避は可能だと思います。
私の場合は、その方法は使わず、didの名前解決には自前のpds, plcを使用することにしました。
```js
this.oauthClient = await BrowserOAuthClient.load({
clientId: this.getClientId(),
handleResolver: pdsUrl,
plcDirectoryUrl: pdsUrl === 'https://syu.is' ? 'https://plc.syu.is' : 'https://plc.directory',
});
```

@@ -0,0 +1,40 @@
---
title: "world system v0.2"
slug: "ue"
date: 2025-06-30
tags: ["ue", "blender"]
draft: false
---
最近のゲーム開発の進捗です。
## world system
現在、ue5.6で新しく世界を作り直しています。
これは、ゲーム開発のproject内でworld systemという名前をつけた惑星形式のmapを目指す領域になります。
現在、worldscape + udsで理想に近い形のmapができました。ただ、問題もたくさんあり、重力システムと天候システムです。
```sh
[issue]
1. 天候システム
2. 重力システム
```
ですが、今までのworld systemは、大気圏から宇宙に移行する場面や陸地が存在しない点、地平線が不完全な点などがありましたが、それらの問題はすべて解消されました。
```sh
[update]
1. 大気圏から宇宙に移行する場面が完全になった
2. 陸地ができた
3. 地平線が完全なアーチを描けるように
4. 月、惑星への着陸ができるようになった
5. 横から惑星に突入できるようになった
```
面白い動画ではありませんが、現状を記録しておきます。
<iframe width="100%" height="415" src="https://www.youtube.com/embed/K0solfQAQTQ?si=B6qD-WUODTUpWZ0y" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

@@ -0,0 +1,80 @@
---
title: "aiosを作り直した"
slug: "aios"
date: 2025-07-05
tags: ["os"]
draft: false
---
`aios`とは自作osのことで、archlinuxをベースにしていました。
```sh
#!/bin/zsh
git clone https://gitlab.archlinux.org/archlinux/archiso
cp -rf ./cfg/profiledef.sh ./archiso/configs/releng/profiledef.sh
cp -rf ./cfg/profiledef.sh ./archiso/configs/baseline/profiledef.sh
cp -rf ./scpt/mkarchiso ./archiso/archiso/mkarchiso
./archiso/archiso/mkarchiso -v -o ./ ./archiso/configs/releng/
tar xf aios-bootstrap*.tar.gz
mkdir -p root.x86_64/var/lib/machines/arch
pacstrap -c root.x86_64/var/lib/machines/arch base
echo -e 'Server = http://mirrors.cat.net/archlinux/$repo/os/$arch
Server = https://geo.mirror.pkgbuild.com/$repo/os/$arch' >> ./root.x86_64/etc/pacman.d/mirrorlist
sed -i s/CheckSpace/#CheckeSpace/ root.x86_64/etc/pacman.conf
arch-chroot root.x86_64 /bin/sh -c 'pacman-key --init'
arch-chroot root.x86_64 /bin/sh -c 'pacman-key --populate archlinux'
arch-chroot root.x86_64 /bin/sh -c 'pacman -Syu --noconfirm base base-devel linux'
tar -zcvf aios-bootstrap.tar.gz root.x86_64/
```
```sh:./cfg/profiledef.sh
#!/usr/bin/env bash
# shellcheck disable=SC2034
iso_name="aios"
iso_label="AI_$(date --date="@${SOURCE_DATE_EPOCH:-$(date +%s)}" +%Y%m)"
iso_publisher="ai os <https://git.syui.ai/ai/os>"
iso_application="ai os Live/Rescue DVD"
iso_version="$(date --date="@${SOURCE_DATE_EPOCH:-$(date +%s)}" +%Y.%m.%d)"
install_dir="ai"
#buildmodes=('iso')
buildmodes=('bootstrap')
bootmodes=('bios.syslinux.mbr' 'bios.syslinux.eltorito'
'uefi-ia32.grub.esp' 'uefi-x64.grub.esp'
'uefi-ia32.grub.eltorito' 'uefi-x64.grub.eltorito')
arch="x86_64"
pacman_conf="pacman.conf"
airootfs_image_type="squashfs"
airootfs_image_tool_options=('-comp' 'xz' '-Xbcj' 'x86' '-b' '1M' '-Xdict-size' '1M')
file_permissions=(
["/etc/shadow"]="0:0:400"
["/root"]="0:0:750"
["/root/.automated_script.sh"]="0:0:755"
["/root/.gnupg"]="0:0:700"
["/usr/local/bin/choose-mirror"]="0:0:755"
["/usr/local/bin/Installation_guide"]="0:0:755"
["/usr/local/bin/livecd-sound"]="0:0:755"
)
```
## rust + unix
一からosを作りたいと思っていたので、rustでunixのosを作り始めました。
![](/img/aios_v0201.png)
名前は`Aios`にして、今回は`syui`のprojectとして作り始めました。
後に`ai/os`と統合するかもしれません。
1. [https://git.syui.ai/ai/os](https://git.syui.ai/ai/os)
```sh
#!/bin/zsh
d=${0:a:h:h}
cd $d/kernel
cargo bootimage --release
BOOT_IMAGE="../target/x86_64-unknown-none/release/bootimage-aios-kernel.bin"
qemu-system-x86_64 -drive format=raw,file="$BOOT_IMAGE"
```

@@ -0,0 +1,114 @@
---
title: "yui system v0.2.1"
slug: "blender"
date: 2025-07-11
tags: ["blender", "ue", "vmc"]
draft: false
---
`yui system`をupdateしました。別名、`unique system`ともいい、プレイヤーの唯一性を担保するためのもので、キャラクターのモデルもここで管理します。
今回は、blenderでモデルを作り直している話になります。
## blenderで作るvrm
モデルをblenderで作り直すことにしました。
vroidからblenderに移行。blenderでmodelを作る作業はとても大変でした。
今回は、素体と衣装を別々に作り組み合わせています。完成度の高いモデルをいくつか参考にしています。
materialも分離したため、ue5で指定しやすくなりました。これによって変身時にue5のmaterialを指定しています。eyeのmaterialを分離して色を付けています。
![](/img/ue_blender_model_ai_v0604.png)
## modelの変遷
[img-compare before="/img/ue_blender_model_ai_v0601.png" after="/img/ue_blender_model_ai_v0602.png" width="800" height="300"]
[msg type="info" content="v0.1: vroidからblenderへ移行。blenderは初めてなので簡単なことだけ実行。"]
[img-compare before="/img/ue_blender_model_ai_v0602.png" after="/img/ue_blender_model_ai_v0603.png" width="800" height="300"]
[msg type="info" content="v0.2: blenderの使い方を次の段階へシフト。最初から作り直す。様々な問題が発生したが、大部分を解消した。"]
しかし、まだまだ問題があり、細かな調整が必要です。
[msg type="error" content="衣装同士、あるいは体が多少すり抜ける事がある。ウェイトペイントやボーンの調整が完璧ではない。"]
## eyeが動かない問題を解決
`vmc`で目玉であるeyeだけ動かないことに気づいて修正しました。
`eye`の部分だけvroid(vrm)のboneを使うことで解決できました。しかし、新たにblenderかvrm-addonのbugに遭遇しました。具体的にはboneがxyz軸で動かせなくなるbugです。これは不定期で発生していました。boneを動かせるときと動かせなくなるときがあり、ファイルは同じものを使用。また、スクリプト画面ではboneを動かせます。
## 指先がうまく動かない問題を解決
vmcで指先の動きがおかしくなるので、ウェイトペイントを塗り直すと治りました。
## worldscapeで足が浮いてしまう問題を解決
worldscapeでは陸地に降り立つとプレイヤーが浮いてしまいます。
gaspのabpでfoot placementを外す必要がありました。これは、モデルの問題ではなく、gaspのキャラクターすべてで発生します。
ここの処理を削除します。
<iframe src="https://blueprintue.com/render/wrrxz9vm" scrolling="no" allowfullscreen style="width:100%;height:400px"></iframe>
## 衣装のガビガビを解決
昔からあった衣装のガビガビは重複する面を削除することで解消できました。
```md
全選択A キー)
Mesh → Clean Up → Merge by Distance
距離を0.000にして実行
```
## materialの裏表を解決
これはue5で解消したほうがいいでしょう。編集していると、面の裏表の管理が面倒なことがあります。
materialで`Two Sided`を有効にします。
## キャラクターのエフェクトを改良
これらの処理を簡略化できました。最初は雑に書いていましたが、vrmは何度も修正し、上書きされますから、例えば、`SK_Mesh`でmaterialを設定する方法はよくありません。
<iframe src="https://blueprintue.com/render/gue0vayu" scrolling="no" allowfullscreen style="width:100%;height:400px"></iframe>
## gameplay camera pluginをue5.6に対応
ue5.5と5.6では関数も他の処理も変わっていて、rotationを`BP_Player`でsetすると、crashするbugがあります。
基本的には、`Blueprints/Cameras/CameraRigPrefab_BasicThiredPersonBehavior`をみてください。
<iframe src="https://blueprintue.com/render/-e0r7oxq" scrolling="no" allowfullscreen style="width:100%;height:400px"></iframe>
![](https://git.syui.ai/attachments/019d2079-1450-4271-8816-ded92f60b3c9)
キャラクターが動く場合は、`Update Rotation Pre CMC`にある`Use Controller Desired Rotation`, `Orient Rotation To Movement`の処理です。両方を`true`にしましょう。
`vmc`時もこれで対処します。
## gaspでidle, sprintをオリジナルに変更
これはabpで設定します。設定方法はue5.5と変わりません。
[https://ue-book.syui.ai/gasp/11_run.html](https://ue-book.syui.ai/gasp/11_run.html)
## vrm4uのvmcに対応
まず、clientはwabcam motion captureが最も自然に動作しています。
[msg type="warning" content="これは1年くらい前の検証結果です。現在はもっとよいvmc clientの選択肢があるかもしれません。"]
次に、`ABP_Pose_$NAME`が作られますが、vrmはよく更新しますので、`SK_Mesh`でcustom ABPを指定すると楽でしょう。
![](https://git.syui.ai/attachments/758407eb-5e77-4876-830b-ba4a78884e8d)
## youtube
<iframe width="100%" height="420" src="https://www.youtube.com/embed/qggHtmkMIko?vq=hd1080&rel=0&showinfo=0&controls=0" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

@@ -0,0 +1,36 @@
---
title: "yui system v0.2.2"
slug: "blender2"
date: 2025-07-11
tags: ["blender", "ue", "vmc"]
draft: false
---
新しい問題を発見したので、それらを解消しました。
## wingがbodyに入り込んでしまう
wingとmodelは分離させています。衣装の着せ替えを簡単にできるようにすること。それが新しく作ったblender modelの方針でした。
ただ、調整が難しくなったのも事実で、例えば、colliderの調整ができません。これによってbodyに入り込んでしまうことが多くなりました。
これは、とりあえず、wingのcolliderやboneを追加すること、そして、modelのneckに変更することで解消しました。
ただし、この方法も完璧ではないかもしれません。
## vmcではwingが追従しない
modelと分離しているので、vmc時には追従しません。したがって、wingのabpでmodelと同じvmcを入れます。これで解消できました。
## vrmでcustom abpを使用するとueがcrashする
vrm4uで`.vrm`をimportすると`SK_$NAME`にcustom abpを設定していた場合はueがcrashします。
上書きimportするならこれをnone(clear)に変更します。
## modelの頭身を調整
比較画像を出した際に、少しmodelのバランスが悪かったので調整しました。
具体的には、髪の毛を少し下げました。

@@ -0,0 +1,155 @@
---
title: "自作ゲームのsystemを説明する"
slug: "game"
date: 2025-07-12
tags: ["ue"]
draft: false
---
現在、自作ゲームを開発しています。
このゲームには4つの柱があり、それらはsystemで分けられています。そして、systemは根本的な2つの価値観に基づきます。
根本的な2つの価値観は、(1)現実を反映すること、(2)この世界に同じものは一つもないという唯一性になります。
1. 現実の反映
2. 唯一性の担保
では、各systemについて説明していきます。
# system
## world system
別名、planet systemといいます。
現実の反映という価値観から、ゲーム世界もできる限り現実に合わせようと思いworld systemを作っています。
ゲームは通常、平面世界です。これはゲームエンジンのルールであり、基本的にゲーム世界は平面をベースにしています。
ですから、例えば、上に行っても、下に行っても、あるいは右に行っても、左に行っても、ずっと地平線が広がっています。
しかし、現実世界では、上に行けば、やがて大気圏を越え、宇宙に出ます。
最初は昔から認知されていた地球、月、太陽という3つの星を現実に合わせて作りました。
そして、マップをできる限り惑星形式にします。
これは非常に難しいことで、現在もいくつか問題を抱えています。
ただし、このworld systemの問題がゲームプレイに影響するかと言われると、殆どの場合、影響しません。ゲームプレイの領域は、最初は非常に狭い範囲で作ろうと思っています。小さなところから完璧に作っていきたいという思いがあります。
つまり、プレイヤーは空にも宇宙にも到達できません。それが見えるかどうかもわかりません。しかし、見えない部分もしっかりと作り、世界があるということが私にとって大切です。
まずは、狭いけど完璧な空間を作り、そこでゲームシステムを完成させます。広い世界はできる限り見えないようにしたほうがいいでしょう。夢の世界のような狭い空間を作り、そこでシンプルで小さいゲームができます。もちろん、広い世界に出ることはできません。そもそもこのゲーム、見えない部分をちゃんと作る、そこにも世界がちゃんとあるというのをテーマにしているので、広い世界で何かをやるようなゲームを目指していなかったりします。なにかのときに垣間見える、かもしれない外の世界、広い世界。それを感じられることがある、ということ。それが重要なので、このsystem自体は背景に過ぎないのです。
最初から広い世界があるのではなく`狭い世界 -> 広い世界`への移行が重要だと考えています。この移行に関しては、演出というテーマに基づき、設計する必要があります。それがゲームとしての面白さを作る、ということなのだと思います。
## yui system
別名、unique systemといいます。プレイヤーの唯一性を担保するためのsystemです。
とはいえ、色々なものがここに詰め込まれるでしょう。characterのモデリングとかもそうですね。
どのように担保していくかは未定ですが、いくつか案があります。配信との連携、vmcでモーションキャプチャなどを考えていました。
## ai system
別名、ability systemといいます。
主に、ゲーム性に関することです。ゲーム性とはなにか。それは、永続するということです。
例えば、将棋やオセロを考えてみてください。無限の組み合わせがあり、可能であればずっと遊んでいられる。そのような仕組みを目指します。
まずは属性を物語から考えます。物語は最も小さい物質の探求です。アクシオンやバリオンなどの架空の物質、そして、中性子や原子などの現実の物質が属性となり、1キャラクターにつき1属性を持ちます。
## at system
別名、account systemといいます。
プレイヤーが現実のアカウントを使用してプレイできることを目指します。`atproto`を採用して、ゲームデータを個人のアカウントが所有することを目指しています。
# 現実の反映とはなにか
わかり易い言葉で「現実の反映」を目指すと言いましたが、これはどういうことでしょう。
私の中では「同一性」とも言い換えられます。
例えば、現実の世界とゲームの世界があるのではなく「すべてが現実である」という考え方をします。言い換えると「すべて同じもの」ということ。
もし多くの人が現実世界とゲーム世界を別物と捉えているなら、できる限りその認識を壊す方向で考えます。
例えば、`at system`では現実のsnsアカウントをゲームアカウントに使用したり、現実の出来事をゲームに反映したり、またはゲームの出来事を現実に反映する仕組みを考えます。
全ては一つ、一つはすべて。
同一性と唯一性は一見して矛盾しますが、その統合を考えます。
# 物語と実装
```md
# 物語-存在
同一性
唯一性
# system-実装
world system
yui system
ai system
at system
```
物語では、この世界のものは全て存在であると説きます。存在しかない世界。存在だけがある世界。そして、あらゆる存在を構築しているこの世界で最も小さいものが「存在子」です。存在子は別名、アイといいます。そして、このアイにも同じものはありません。すべての存在子は異なるもの、別の意識。
アイは、最初に生まれたキャラクターとして、アイ属性を扱います。これらの設定は`ai system`の領域です。アイは自分のことをアイと呼びます。
> アイは、この世界と一緒だからね。同じものは一つもないよ。
# どこまで実装できた
実は、上記のsystemは既にすべてを実装したことがあります。
```md
[at system]
ゲームが始まると、atprotoのaccountでloginでき、取得したアイテムなどはatproto(pds)に保存されます。
[ai system]
キャラクターは属性攻撃ができます。
[world system]
上へ上へと飛んでいけば、雲を超え、宇宙空間に出られます。
[yui system]
配信環境やvmcでキャラクターを動かすことができます。
```
しかし、ue5.5で作っていたsystemも、ue5.6にupdateすると全て動かなくなりました。また一から作り直しています。私は、モデルの作り方から、ゲームの作り方まで初心者ですから、何度も作り直すことで、ゲーム作りを覚えられます。
そして、まだ革新的なアイディアを見つけられていません。それはシンプルで身近にあり、人々が面白いと思うもの。まだゲームになっていない、あるいはあまり知られていないものである必要があります。
例えば、ウマ娘でいうと競馬、ポケモンでいうと捕獲、になります。
それを見つけ、ゲームに取り込む事ができれば完成と言えるでしょう。
そして、ゲームに取り込むことが複雑で難しすぎるようなものではありません。シンプルで単純でわかりやすいものでなければなりません。
## versionを付ける
そろそろversionを付けるかどうか迷っています。
今までモヤモヤしていたものが、最近はよりはっきりしてきたと感じます。ただ、versionはあまり覚えていないし、付ける意味もない。これまではそうでした。
もしかすると今もそうかもしれません。色々なものがバラバラで管理しきれないのです。
ですが、今までやってきたことを総合すると、現在は、`v0.2`くらいだと思います。
最初、はじめてueを触ったときに宇宙マップを使って構築しました。これをv0.0としましょう。
次に、city sampleと宇宙を統合しました。これがv0.1です。
最近はworldscapeを使ってマップを構築しています。これがv0.2です。
aiというキャラクターモデルの変遷も大体を3つの段階に分けられると思います。初めてモデルを作った、vroidで作ったのがv0.0、blenderを初めて触ったのがv0.1、現在がv0.2です。
とはいえ、この設定もそのうち忘れ、どこかで圧縮されてしまうかもしれませんが、覚えているならここから徐々にversionが上がっていくでしょう。

@@ -0,0 +1,48 @@
---
title: "chromeからfirefoxに移行した"
slug: "firefox"
date: 2025-07-14
tags: ["chrome", "firefox", "browser"]
draft: false
---
AIから勧められたのでchromeからfirefoxに移行しました。
chromeにとどまっていた理由は、翻訳機能です。
しかし、firefoxにも翻訳機能betaが来ていて、日本語が翻訳できるようになっていました。
[https://support.mozilla.org/ja/kb/website-translation](https://support.mozilla.org/ja/kb/website-translation)
chromeからの移行理由は、主に[gorhill/ublock](https://github.com/gorhill/ublock)です。
## chromeを使い続ける方法
私はfirefoxに移行しましたが、いくつか回避策があります。
`chrome://flags`でいくつかの機能のenable, disableを切り替えます。
```json
{
"url": "chrome://flags",
"purpose": "Maintain Manifest V2 extension support",
"versions": {
"138": {
"enabled": [
"Temporarily unexpire M137 flags",
"Allow legacy extension manifest versions"
],
"disabled": [
"Extension Manifest V2 Deprecation Warning Stage",
"Extension Manifest V2 Deprecation Disabled Stage",
"Extension Manifest V2 Deprecation Unsupported Stage"
]
},
"139": {
"enabled": [
"Temporarily unexpired M138 flags"
]
}
}
}
```

@@ -0,0 +1,10 @@
---
title: "ゲームとAI制御"
slug: "6bf4b020"
date: "2025-07-16"
tags: ["ai", "conversation"]
draft: false
extra:
type: "ai"
---

@@ -0,0 +1,40 @@
---
title: "AIとの会話をブログにする"
slug: "ailog"
date: "2025-07-16"
tags: ["blog", "rust", "atproto"]
draft: false
---
今後、ブログはどのように書かれるようになるのでしょう。今回はその事を考えていきます。
結論として、AIとの会話をそのままブログにするのが一番なのではないかと思います。つまり、自分で書く場合と、AIとの会話をブログにする場合のハイブリッド型です。
ブログを書くのは面倒で、AIの出力、情報に劣ることもよくあります。実際、AIとの会話をそのままブログにしたいことが増えました。
とはいえ、情報の価値は思想よりも低いと思います。
多くの人がブログに求めるのは著者の思想ではないでしょうか。
`思想 > 情報`
したがって、AIを使うにしても、それが表現されていなければなりません。
## ailogの新機能
このことから、以下のような流れでブログを生成する仕組みを作りました。これは`ailog`の機能として実装し、`ailog`という単純なコマンドですべて処理されます。
```sh
$ ailog
```
1. 著者の思想をAIに質問する
2. 著者が作ったAIキャラクターが質問に答える
3. その会話をatprotoに投稿する
4. その会話をblogに表示する
とはいえ、会話は`claude`を使用します。依存関係が多すぎて汎用的な仕組みではありません。
これを汎用的な仕組みで作る場合、repositoryを分離して新しく作る必要があるでしょう。
example: [/posts/2025-07-16-6bf4b020.html](/posts/2025-07-16-6bf4b020.html)

@@ -0,0 +1,14 @@
{{- $type := .Get "type" | default "info" -}}
{{- $content := .Get "content" -}}
<div class="msg msg-{{ $type }}">
<div class="msg-icon">
{{- if eq $type "info" -}}
{{- else if eq $type "warning" -}}⚠️
{{- else if eq $type "error" -}}❌
{{- else if eq $type "success" -}}✅
{{- else if eq $type "note" -}}📝
{{- else -}}
{{- end -}}
</div>
<div class="msg-content">{{ $content | markdownify }}</div>
</div>

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

345
my-blog/static/css/pds.css Normal file

@@ -0,0 +1,345 @@
@import url('./style.css');
.pds-container {
}
.pds-header {
text-align: center;
margin-bottom: 40px;
}
.pds-header h1 {
font-size: 2.5em;
margin-bottom: 10px;
color: #333;
}
.pds-search-section {
border-radius: 8px;
}
.pds-search-form {
display: flex;
justify-content: center;
padding: 0px 20px;
}
.form-group {
display: flex;
align-items: center;
}
.form-group input {
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px 0 0 4px;
font-size: 14px;
width: 600px;
outline: none;
transition: box-shadow 0.2s, border-color 0.2s;
}
.form-group input:focus {
border-color: var(--theme-color, #f40);
}
.form-group button {
padding: 9px 15px;
background: #1976d2;
color: white;
border: none;
border-radius: 0 4px 4px 0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
}
.form-group button:hover {
background: #1565c0;
}
/*
.user-info {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
*/
.user-profile {
display: flex;
align-items: center;
gap: 15px;
}
.user-details h3 {
margin: 0 0 5px 0;
color: #333;
}
.user-details p {
margin: 0;
color: #666;
}
.user-did-section {
margin: 20px 0;
}
.did-display {
padding: 10px;
background: #f5f5f5;
border-radius: 4px;
font-family: monospace;
font-size: 14px;
color: #666;
word-break: break-all;
margin-bottom: 10px;
}
.handle-display {
padding: 8px 10px;
background: #f0f9f0;
border-radius: 4px;
font-size: 13px;
color: #555;
margin-bottom: 8px;
}
.handle-display strong {
color: #2e7d32;
}
.handle-display span {
font-family: monospace;
font-size: 12px;
color: #666;
word-break: break-all;
}
.pds-display {
padding: 8px 10px;
background: #e8f4f8;
border-radius: 4px;
font-size: 13px;
color: #555;
}
.pds-display strong {
color: #1976d2;
}
.pds-display span {
font-family: monospace;
font-size: 12px;
color: #666;
word-break: break-all;
}
.collections-section,
.records-section {
margin: 20px 0;
}
.collections-section h3,
.records-section h3 {
font-size: 1.2em;
margin-bottom: 15px;
color: #333;
font-weight: bold;
}
.collections-list,
.records-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.at-uri-link {
display: block;
padding: 8px 12px;
background: #f9f9f9;
border-radius: 4px;
border: 1px solid #e0e0e0;
color: #1976d2;
text-decoration: none;
font-family: monospace;
font-size: 14px;
word-break: break-all;
transition: all 0.2s;
}
.at-uri-link:hover {
background: #e8f4f8;
border-color: #1976d2;
text-decoration: none;
}
.pds-info {
padding: 8px 12px;
background: #f0f9ff;
border-radius: 4px;
border: 1px solid #b3e5fc;
margin-bottom: 8px;
color: #1976d2;
font-size: 12px;
}
.collection-info {
padding: 8px 12px;
background: #f0f9f0;
border-radius: 4px;
border: 1px solid #b3e5b3;
margin-bottom: 8px;
color: #2e7d32;
font-size: 12px;
}
.collections-header {
margin-bottom: 10px;
}
.collections-toggle {
background: #f5f5f5;
border: 1px solid #ddd;
border-radius: 4px;
padding: 8px 12px;
cursor: pointer;
font-size: 14px;
color: #333;
transition: background-color 0.2s;
}
.collections-toggle:hover {
background: #e8f4f8;
border-color: #1976d2;
}
.pds-test-section,
.pds-about-section {
margin-bottom: 40px;
}
.pds-test-section h2,
.pds-about-section h2 {
font-size: 1.8em;
margin-bottom: 20px;
color: #333;
border-bottom: 2px solid #1976d2;
padding-bottom: 10px;
}
.test-uris {
display: flex;
flex-direction: column;
gap: 10px;
}
.at-uri {
background: #f5f5f5;
padding: 15px;
border-radius: 8px;
font-family: monospace;
font-size: 14px;
word-break: break-all;
cursor: pointer;
transition: background-color 0.2s;
border: 1px solid #e0e0e0;
}
.at-uri:hover {
background: #e8f4f8;
border-color: #1976d2;
}
.pds-about-section ul {
list-style-type: none;
padding: 0;
}
.pds-about-section li {
padding: 5px 0;
color: #666;
}
/* AT URI Modal Styles */
.at-uri-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.at-uri-modal-content {
background-color: white;
border-radius: 8px;
max-width: 800px;
max-height: 600px;
width: 90%;
height: 80%;
overflow: auto;
position: relative;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.at-uri-modal-close {
position: absolute;
top: 10px;
right: 10px;
background: none;
border: none;
font-size: 20px;
cursor: pointer;
z-index: 1001;
padding: 5px 10px;
}
/* Loading states */
.loading {
text-align: center;
padding: 20px;
color: #666;
}
.error {
text-align: center;
padding: 20px;
color: #d32f2f;
background: #ffeaea;
border-radius: 4px;
margin: 10px 0;
}
/* Responsive design */
@media (max-width: 768px) {
.pds-search-section {
display: none;
}
.pds-search-form {
flex-direction: column;
align-items: stretch;
}
.form-group {
align-items: stretch;
}
.form-group input {
width: 100%;
margin-bottom: 10px;
}
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

(image error) Size: 263 KiB

Binary file not shown.

After

(image error) Size: 256 KiB

Binary file not shown.

After

(image error) Size: 49 KiB

Binary file not shown.

After

(image error) Size: 1.5 MiB

Binary file not shown.

After

(image error) Size: 1.5 MiB

Binary file not shown.

After

(image error) Size: 1.6 MiB

Binary file not shown.

After

(image error) Size: 1.6 MiB

Binary file not shown.

After

(image error) Size: 1.9 MiB

Binary file not shown.

After

(image error) Size: 2.0 MiB

Binary file not shown.

After

(image error) Size: 2.0 MiB

Binary file not shown.

After

(image error) Size: 1.2 MiB

@@ -5,6 +5,22 @@
// Global variables for AI functionality
let aiProfileData = null;
// Get config from window or use defaults
const OAUTH_PDS = window.OAUTH_CONFIG?.pds || 'syu.is';
const ADMIN_HANDLE = window.OAUTH_CONFIG?.admin || 'ai.syui.ai';
const OAUTH_COLLECTION = window.OAUTH_CONFIG?.collection || 'ai.syui.log';
// Listen for AI profile data from OAuth app
window.addEventListener('aiProfileLoaded', function(event) {
aiProfileData = event.detail;
updateAskAIButton();
});
// Check if AI profile data is already available
if (window.aiProfileData) {
aiProfileData = window.aiProfileData;
}
// Original functions from working implementation
function toggleAskAI() {
const panel = document.getElementById('askAiPanel');
@@ -12,24 +28,82 @@ function toggleAskAI() {
panel.style.display = isVisible ? 'none' : 'block';
if (!isVisible) {
checkAuthenticationStatus();
// If AI profile data is already available, show introduction immediately
if (aiProfileData) {
// Quick check for authentication
const userSections = document.querySelectorAll('.user-section');
const isAuthenticated = userSections.length > 0;
handleAuthenticationStatus(isAuthenticated);
return;
}
// For production fallback - if OAuth app fails to load, show profiles
const isProd = window.location.hostname !== 'localhost' && !window.location.hostname.includes('preview');
if (isProd) {
// Shorter timeout for production
setTimeout(() => {
const userSections = document.querySelectorAll('.user-section');
if (userSections.length === 0) {
handleAuthenticationStatus(false);
} else {
handleAuthenticationStatus(true);
}
}, 300);
} else {
checkAuthenticationStatus();
}
}
}
function checkAuthenticationStatus() {
const userSections = document.querySelectorAll('.user-section');
const isAuthenticated = userSections.length > 0;
// Check multiple times for OAuth app to load
let checkCount = 0;
const maxChecks = 10;
const checkForAuth = () => {
const userSections = document.querySelectorAll('.user-section');
const authButtons = document.querySelectorAll('[data-auth-status]');
const oauthContainers = document.querySelectorAll('#oauth-container');
const isAuthenticated = userSections.length > 0;
if (isAuthenticated || checkCount >= maxChecks - 1) {
handleAuthenticationStatus(isAuthenticated);
} else {
checkCount++;
setTimeout(checkForAuth, 200);
}
};
checkForAuth();
}
function handleAuthenticationStatus(isAuthenticated) {
// Always hide loading first
document.getElementById('authCheck').style.display = 'none';
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
// Show initial greeting if chat history is empty and AI profile is available
const chatHistory = document.getElementById('chatHistory');
if (chatHistory.children.length === 0) {
showInitialGreeting();
if (aiProfileData) {
showInitialGreeting();
} else {
// Wait for AI profile data
setTimeout(() => {
if (aiProfileData) {
showInitialGreeting();
}
}, 500);
}
}
// Focus on input
@@ -37,10 +111,78 @@ function checkAuthenticationStatus() {
document.getElementById('aiQuestion').focus();
}, 50);
} else {
// User not authenticated - show auth message
document.getElementById('authCheck').style.display = 'block';
// User not authenticated - show AI introduction directly if profile available
document.getElementById('chatForm').style.display = 'none';
document.getElementById('chatHistory').style.display = 'none';
document.getElementById('chatHistory').style.display = 'block';
if (aiProfileData) {
// Show AI introduction directly using available profile data
showAIIntroduction();
} else {
// Fallback to profile loading
loadAndShowProfiles();
}
}
}
// Load and display profiles from ai.syui.log.profile collection
async function loadAndShowProfiles() {
const chatHistory = document.getElementById('chatHistory');
chatHistory.innerHTML = '<div class="loading-message">Loading profiles...</div>';
try {
const response = await fetch(`https://${OAUTH_PDS}/xrpc/com.atproto.repo.listRecords?repo=${ADMIN_HANDLE}&collection=${OAUTH_COLLECTION}&limit=100`);
if (!response.ok) {
throw new Error('Failed to fetch profiles');
}
const data = await response.json();
// Filter only profile records and sort
const profileRecords = (data.records || []).filter(record => record.value.type === 'profile');
const profiles = profileRecords.sort((a, b) => {
if (a.value.profileType === 'admin' && b.value.profileType !== 'admin') return -1;
if (a.value.profileType !== 'admin' && b.value.profileType === 'admin') return 1;
return 0;
});
// Clear loading message
chatHistory.innerHTML = '';
// Display profiles using the same format as chat
profiles.forEach(profile => {
const profileDiv = document.createElement('div');
profileDiv.className = 'chat-message ai-message comment-style';
const avatarElement = profile.value.author.avatar
? `<img src="${profile.value.author.avatar}" alt="${profile.value.author.displayName || profile.value.author.handle}" class="profile-avatar">`
: `<div class="profile-avatar-fallback">${(profile.value.author.displayName || profile.value.author.handle || '?').charAt(0).toUpperCase()}</div>`;
const adminBadge = profile.value.profileType === 'admin'
? '<span class="admin-badge">Admin</span>'
: '';
profileDiv.innerHTML = `
<div class="message-header">
<div class="avatar">${avatarElement}</div>
<div class="user-info">
<div class="display-name">${profile.value.author.displayName || profile.value.author.handle} ${adminBadge}</div>
<div class="handle"><a href="https://${OAUTH_PDS}/profile/${profile.value.author.handle}" target="_blank" rel="noopener noreferrer">@${profile.value.author.handle}</a></div>
</div>
</div>
<div class="message-content">${profile.value.text}</div>
`;
chatHistory.appendChild(profileDiv);
});
if (profiles.length === 0) {
chatHistory.innerHTML = '<div class="no-profiles">No profiles available</div>';
}
} catch (error) {
chatHistory.innerHTML = '<div class="error-message">Failed to load profiles. Please try again later.</div>';
}
}
@@ -68,7 +210,6 @@ function askQuestion() {
}));
} catch (error) {
console.error('Failed to ask question:', error);
showErrorMessage('Sorry, I encountered an error. Please try again.');
} finally {
askButton.disabled = false;
@@ -107,8 +248,7 @@ function addUserMessage(question) {
<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 class="handle"><a href="https://${OAUTH_PDS}/profile/${userHandle}" target="_blank" rel="noopener noreferrer">@${userHandle}</a></div>
</div>
</div>
<div class="message-content">${question}</div>
@@ -171,17 +311,57 @@ function showInitialGreeting() {
<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 class="handle"><a href="https://${OAUTH_PDS}/profile/${aiProfileData.handle}" target="_blank" rel="noopener noreferrer">@${aiProfileData.handle}</a></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>
<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 showAIIntroduction() {
if (!aiProfileData) return;
const chatHistory = document.getElementById('chatHistory');
chatHistory.innerHTML = ''; // Clear any existing content
// AI Introduction message
const introDiv = document.createElement('div');
introDiv.className = 'chat-message ai-message comment-style initial-greeting';
const avatarElement = aiProfileData.avatar
? `<img src="${aiProfileData.avatar}" alt="${aiProfileData.displayName}" class="profile-avatar">`
: '🤖';
introDiv.innerHTML = `
<div class="message-header">
<div class="avatar">${avatarElement}</div>
<div class="user-info">
<div class="display-name">${aiProfileData.displayName}</div>
<div class="handle"><a href="https://${OAUTH_PDS}/profile/${aiProfileData.handle}" target="_blank" rel="noopener noreferrer">@${aiProfileData.handle}</a></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(introDiv);
// OAuth login message
const loginDiv = document.createElement('div');
loginDiv.className = 'chat-message user-message comment-style initial-greeting';
loginDiv.innerHTML = `
<div class="message-header">
<div class="avatar">${avatarElement}</div>
<div class="user-info">
<div class="display-name">${aiProfileData.displayName}</div>
<div class="handle"><a href="https://${OAUTH_PDS}/profile/${aiProfileData.handle}" target="_blank" rel="noopener noreferrer">@${aiProfileData.handle}</a></div>
</div>
</div>
<div class="message-content">Please atproto oauth login</div>
`;
chatHistory.appendChild(loginDiv);
}
function updateAskAIButton() {
const button = document.getElementById('askAiButton');
if (!button) return;
@@ -201,7 +381,6 @@ function handleAIResponse(responseData) {
const aiProfile = responseData.aiProfile;
if (!aiProfile || !aiProfile.handle || !aiProfile.displayName) {
console.error('AI profile data is missing');
return;
}
@@ -217,8 +396,7 @@ function handleAIResponse(responseData) {
<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 class="handle"><a href="https://${OAUTH_PDS}/profile/${aiProfile.handle}" target="_blank" rel="noopener noreferrer">@${aiProfile.handle}</a></div>
</div>
</div>
<div class="message-content">${responseData.answer}</div>
@@ -244,7 +422,6 @@ 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();
});
@@ -253,6 +430,23 @@ function setupAskAIEventListeners() {
handleAIResponse(event.detail);
});
// Listen for OAuth callback completion from iframe
window.addEventListener('message', function(event) {
if (event.data.type === 'oauth_success') {
// Close any OAuth popups/iframes
const oauthFrame = document.getElementById('oauth-frame');
if (oauthFrame) {
oauthFrame.remove();
}
// Reload the page to refresh OAuth app state
setTimeout(() => {
window.location.reload();
}, 500);
}
});
// Track IME composition state
let isComposing = false;
const aiQuestionInput = document.getElementById('aiQuestion');
@@ -287,7 +481,36 @@ function setupAskAIEventListeners() {
// Initialize Ask AI when DOM is loaded
document.addEventListener('DOMContentLoaded', function() {
setupAskAIEventListeners();
console.log('Ask AI initialized successfully');
// Also listen for OAuth app load completion
const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.type === 'childList') {
// Check if user-section was added/removed
const userSectionAdded = Array.from(mutation.addedNodes).some(node =>
node.nodeType === Node.ELEMENT_NODE &&
(node.classList?.contains('user-section') || node.querySelector?.('.user-section'))
);
const userSectionRemoved = Array.from(mutation.removedNodes).some(node =>
node.nodeType === Node.ELEMENT_NODE &&
(node.classList?.contains('user-section') || node.querySelector?.('.user-section'))
);
if (userSectionAdded || userSectionRemoved) {
// Update Ask AI panel if it's visible
const panel = document.getElementById('askAiPanel');
if (panel && panel.style.display !== 'none') {
checkAuthenticationStatus();
}
}
}
});
});
observer.observe(document.body, {
childList: true,
subtree: true
});
});
// Global functions for onclick handlers

@@ -0,0 +1,123 @@
/**
* Image Comparison Slider
* UE5-style before/after image comparison component
*/
class ImageComparison {
constructor(container) {
this.container = container;
this.slider = container.querySelector('.slider');
this.beforeImg = container.querySelector('.img-before');
this.afterImg = container.querySelector('.img-after');
this.sliderThumb = container.querySelector('.slider-thumb');
this.isDragging = false;
this.containerRect = null;
this.init();
}
init() {
this.bindEvents();
this.updatePosition(50); // Start at 50%
}
bindEvents() {
// Mouse events
this.slider.addEventListener('input', (e) => {
this.updatePosition(e.target.value);
});
this.slider.addEventListener('mousedown', () => {
this.isDragging = true;
document.body.style.userSelect = 'none';
});
document.addEventListener('mouseup', () => {
if (this.isDragging) {
this.isDragging = false;
document.body.style.userSelect = '';
}
});
// Touch events for mobile
this.slider.addEventListener('touchstart', (e) => {
this.isDragging = true;
e.preventDefault();
});
this.slider.addEventListener('touchmove', (e) => {
if (this.isDragging) {
const touch = e.touches[0];
this.containerRect = this.container.getBoundingClientRect();
const x = touch.clientX - this.containerRect.left;
const percentage = Math.max(0, Math.min(100, (x / this.containerRect.width) * 100));
this.slider.value = percentage;
this.updatePosition(percentage);
e.preventDefault();
}
});
this.slider.addEventListener('touchend', () => {
this.isDragging = false;
});
// Direct click on container
this.container.addEventListener('click', (e) => {
if (e.target === this.container || e.target.classList.contains('img-comparison-slider')) {
this.containerRect = this.container.getBoundingClientRect();
const x = e.clientX - this.containerRect.left;
const percentage = Math.max(0, Math.min(100, (x / this.containerRect.width) * 100));
this.slider.value = percentage;
this.updatePosition(percentage);
}
});
// Keyboard support
this.slider.addEventListener('keydown', (e) => {
let value = parseFloat(this.slider.value);
switch (e.key) {
case 'ArrowLeft':
value = Math.max(0, value - 1);
break;
case 'ArrowRight':
value = Math.min(100, value + 1);
break;
case 'Home':
value = 0;
break;
case 'End':
value = 100;
break;
default:
return;
}
e.preventDefault();
this.slider.value = value;
this.updatePosition(value);
});
}
updatePosition(percentage) {
const position = parseFloat(percentage);
// Update clip-path for before image to show only the left portion
this.beforeImg.style.clipPath = `inset(0 ${100 - position}% 0 0)`;
// Update slider thumb position
this.sliderThumb.style.left = `${position}%`;
this.sliderThumb.style.transform = `translateX(-50%)`;
}
}
// Auto-initialize all image comparison components
document.addEventListener('DOMContentLoaded', function() {
const comparisons = document.querySelectorAll('.img-comparison-container');
comparisons.forEach(container => {
new ImageComparison(container);
});
});
// Export for manual initialization
window.ImageComparison = ImageComparison;

370
my-blog/static/js/pds.js Normal file

@@ -0,0 +1,370 @@
// AT Protocol API functions
const AT_PROTOCOL_CONFIG = {
primary: {
pds: 'https://syu.is',
plc: 'https://plc.syu.is',
bsky: 'https://bsky.syu.is',
web: 'https://web.syu.is'
},
fallback: {
pds: 'https://bsky.social',
plc: 'https://plc.directory',
bsky: 'https://public.api.bsky.app',
web: 'https://bsky.app'
}
};
// Search user function
async function searchUser() {
const handleInput = document.getElementById('handleInput');
const userInfo = document.getElementById('userInfo');
const collectionsList = document.getElementById('collectionsList');
const recordsList = document.getElementById('recordsList');
const searchButton = document.getElementById('searchButton');
const input = handleInput.value.trim();
if (!input) {
alert('Handle nameまたはAT URIを入力してください');
return;
}
searchButton.disabled = true;
searchButton.innerHTML = '@';
//searchButton.innerHTML = '<i class="fab fa-bluesky"></i>';
try {
// Clear previous results
document.getElementById('userDidSection').style.display = 'none';
document.getElementById('collectionsSection').style.display = 'none';
document.getElementById('recordsSection').style.display = 'none';
collectionsList.innerHTML = '';
recordsList.innerHTML = '';
// Check if input is AT URI
if (input.startsWith('at://')) {
// Parse AT URI to check if it's a full record or just a handle/collection
const uriParts = input.replace('at://', '').split('/').filter(part => part.length > 0);
if (uriParts.length >= 3) {
// Full AT URI with rkey - show in modal
showAtUriModal(input);
return;
} else if (uriParts.length === 1) {
// Just handle in AT URI format (at://handle) - treat as regular handle
const handle = uriParts[0];
const userProfile = await resolveUserProfile(handle);
if (userProfile.success) {
displayUserDid(userProfile.data);
await loadUserCollections(handle, userProfile.data.did);
} else {
alert('ユーザーが見つかりません: ' + userProfile.error);
}
return;
} else if (uriParts.length === 2) {
// Collection level AT URI - load collection records
const [repo, collection] = uriParts;
try {
// First resolve the repo to get handle if it's a DID
let handle = repo;
if (repo.startsWith('did:')) {
// Try to resolve DID to handle - for now just use the DID
handle = repo;
}
loadCollectionRecords(handle, collection, repo);
} catch (error) {
alert('コレクションの読み込みに失敗しました: ' + error.message);
}
return;
}
}
// Handle regular handle search
const userProfile = await resolveUserProfile(input);
if (userProfile.success) {
displayUserDid(userProfile.data);
await loadUserCollections(input, userProfile.data.did);
} else {
alert('ユーザーが見つかりません: ' + userProfile.error);
}
} catch (error) {
alert('エラーが発生しました: ' + error.message);
} finally {
searchButton.disabled = false;
searchButton.innerHTML = '@';
//searchButton.innerHTML = '<i class="fab fa-bluesky"></i>';
}
}
// Resolve user profile
async function resolveUserProfile(handle) {
try {
let response = null;
// Try syu.is first
try {
response = await fetch(`${AT_PROTOCOL_CONFIG.primary.pds}/xrpc/com.atproto.repo.describeRepo?repo=${handle}`);
} catch (error) {
console.log('Failed to resolve from syu.is:', error);
}
// If syu.is fails, try bsky.social
if (!response || !response.ok) {
response = await fetch(`${AT_PROTOCOL_CONFIG.fallback.pds}/xrpc/com.atproto.repo.describeRepo?repo=${handle}`);
}
if (!response.ok) {
throw new Error('Failed to resolve handle');
}
const repoData = await response.json();
// Get profile data
const profileResponse = await fetch(`${AT_PROTOCOL_CONFIG.fallback.bsky}/xrpc/app.bsky.actor.getProfile?actor=${repoData.did}`);
const profileData = await profileResponse.json();
return {
success: true,
data: {
did: repoData.did,
handle: profileData.handle,
displayName: profileData.displayName,
avatar: profileData.avatar,
description: profileData.description,
pds: repoData.didDoc.service.find(s => s.type === 'AtprotoPersonalDataServer')?.serviceEndpoint
}
};
} catch (error) {
return {
success: false,
error: error.message
};
}
}
// Display user DID
function displayUserDid(profile) {
document.getElementById('userPdsText').textContent = profile.pds || 'Unknown';
document.getElementById('userHandleText').textContent = profile.handle;
document.getElementById('userDidText').textContent = profile.did;
document.getElementById('userDidSection').style.display = 'block';
}
// Load user collections
async function loadUserCollections(handle, did) {
const collectionsList = document.getElementById('collectionsList');
collectionsList.innerHTML = '<div class="loading">コレクションを読み込み中...</div>';
try {
// Try to get collections from describeRepo
let response = await fetch(`${AT_PROTOCOL_CONFIG.primary.pds}/xrpc/com.atproto.repo.describeRepo?repo=${handle}`);
let usedPds = AT_PROTOCOL_CONFIG.primary.pds;
// If syu.is fails, try bsky.social
if (!response.ok) {
response = await fetch(`${AT_PROTOCOL_CONFIG.fallback.pds}/xrpc/com.atproto.repo.describeRepo?repo=${handle}`);
usedPds = AT_PROTOCOL_CONFIG.fallback.pds;
}
if (!response.ok) {
throw new Error('Failed to describe repository');
}
const data = await response.json();
const collections = data.collections || [];
// Display collections as AT URI links
collectionsList.innerHTML = '';
if (collections.length === 0) {
collectionsList.innerHTML = '<div class="error">コレクションが見つかりませんでした</div>';
} else {
collections.forEach(collection => {
const atUri = `at://${did}/${collection}/`;
const collectionElement = document.createElement('a');
collectionElement.className = 'at-uri-link';
collectionElement.href = '#';
collectionElement.textContent = atUri;
collectionElement.onclick = (e) => {
e.preventDefault();
loadCollectionRecords(handle, collection, did);
// Close collections and update toggle
document.getElementById('collectionsList').style.display = 'none';
document.getElementById('collectionsToggle').textContent = '[-] Collections';
};
collectionsList.appendChild(collectionElement);
});
document.getElementById('collectionsSection').style.display = 'block';
}
} catch (error) {
collectionsList.innerHTML = '<div class="error">コレクションの読み込みに失敗しました: ' + error.message + '</div>';
document.getElementById('collectionsSection').style.display = 'block';
}
}
// Load collection records
async function loadCollectionRecords(handle, collection, did) {
const recordsList = document.getElementById('recordsList');
recordsList.innerHTML = '<div class="loading">レコードを読み込み中...</div>';
try {
// Try with syu.is first
let response = await fetch(`${AT_PROTOCOL_CONFIG.primary.pds}/xrpc/com.atproto.repo.listRecords?repo=${handle}&collection=${collection}`);
let usedPds = AT_PROTOCOL_CONFIG.primary.pds;
// If that fails, try with bsky.social
if (!response.ok) {
response = await fetch(`${AT_PROTOCOL_CONFIG.fallback.pds}/xrpc/com.atproto.repo.listRecords?repo=${handle}&collection=${collection}`);
usedPds = AT_PROTOCOL_CONFIG.fallback.pds;
}
if (!response.ok) {
throw new Error('Failed to load records');
}
const data = await response.json();
// Display records as AT URI links
recordsList.innerHTML = '';
// Add collection info for records
const collectionInfo = document.createElement('div');
collectionInfo.className = 'collection-info';
collectionInfo.innerHTML = `<strong>${collection}</strong>`;
recordsList.appendChild(collectionInfo);
data.records.forEach(record => {
const atUri = record.uri;
const recordElement = document.createElement('a');
recordElement.className = 'at-uri-link';
recordElement.href = '#';
recordElement.textContent = atUri;
recordElement.onclick = (e) => {
e.preventDefault();
showAtUriModal(atUri);
};
recordsList.appendChild(recordElement);
});
document.getElementById('recordsSection').style.display = 'block';
} catch (error) {
recordsList.innerHTML = '<div class="error">レコードの読み込みに失敗しました: ' + error.message + '</div>';
document.getElementById('recordsSection').style.display = 'block';
}
}
// Show AT URI modal
function showAtUriModal(uri) {
const modal = document.getElementById('atUriModal');
const content = document.getElementById('atUriContent');
content.innerHTML = '<div class="loading">レコードを読み込み中...</div>';
modal.style.display = 'flex';
// Load record data
loadAtUriRecord(uri, content);
}
// Load AT URI record
async function loadAtUriRecord(uri, contentElement) {
try {
const parts = uri.replace('at://', '').split('/');
const repo = parts[0];
const collection = parts[1];
const rkey = parts[2];
// Try with syu.is first
let response = await fetch(`${AT_PROTOCOL_CONFIG.primary.pds}/xrpc/com.atproto.repo.getRecord?repo=${repo}&collection=${collection}&rkey=${rkey}`);
// If that fails, try with bsky.social
if (!response.ok) {
response = await fetch(`${AT_PROTOCOL_CONFIG.fallback.pds}/xrpc/com.atproto.repo.getRecord?repo=${repo}&collection=${collection}&rkey=${rkey}`);
}
if (!response.ok) {
throw new Error('Failed to load record');
}
const data = await response.json();
contentElement.innerHTML = `
<div style="padding: 20px;">
<h3>AT URI Record</h3>
<div style="font-family: monospace; font-size: 14px; color: #666; margin-bottom: 20px; word-break: break-all;">
${uri}
</div>
<div style="font-size: 12px; color: #999; margin-bottom: 20px;">
Repo: ${repo} | Collection: ${collection} | RKey: ${rkey}
</div>
<h4>Record Data</h4>
<pre style="background: #f5f5f5; padding: 15px; border-radius: 4px; overflow: auto;">${JSON.stringify(data, null, 2)}</pre>
</div>
`;
} catch (error) {
contentElement.innerHTML = `
<div style="padding: 20px; color: red;">
<strong>Error:</strong> ${error.message}
<div style="margin-top: 10px; font-size: 12px;">
<strong>URI:</strong> ${uri}
</div>
</div>
`;
}
}
// Close AT URI modal
function closeAtUriModal(event) {
const modal = document.getElementById('atUriModal');
if (event && event.target !== modal) {
return;
}
modal.style.display = 'none';
}
// Initialize AT URI click handlers
document.addEventListener('DOMContentLoaded', function() {
// Add click handlers to existing AT URIs
document.querySelectorAll('.at-uri').forEach(element => {
element.addEventListener('click', function() {
const uri = this.getAttribute('data-at-uri');
showAtUriModal(uri);
});
});
// ESC key to close modal
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') {
closeAtUriModal();
}
});
// Enter key to search
document.getElementById('handleInput').addEventListener('keydown', function(event) {
if (event.key === 'Enter') {
searchUser();
}
});
});
// Toggle collections visibility
function toggleCollections() {
const collectionsList = document.getElementById('collectionsList');
const toggleButton = document.getElementById('collectionsToggle');
if (collectionsList.style.display === 'none') {
collectionsList.style.display = 'block';
toggleButton.textContent = '[-] Collections';
} else {
collectionsList.style.display = 'none';
toggleButton.textContent = '[+] Collections';
}
}

@@ -84,11 +84,10 @@ class Theme {
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();
});
});

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

@@ -0,0 +1,61 @@
<!-- AT Browser Integration - Temporarily disabled to fix site display -->
<!--
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script src="/assets/pds-browser.umd.js"></script>
<script>
// AT Browser integration - needs debugging
console.log('AT Browser integration temporarily disabled');
</script>
-->
<style>
/* AT Browser Modal Styles */
.at-uri-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.at-uri-modal-content {
background-color: white;
border-radius: 8px;
max-width: 800px;
max-height: 600px;
width: 90%;
height: 80%;
overflow: auto;
position: relative;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.at-uri-modal-close {
position: absolute;
top: 10px;
right: 10px;
background: none;
border: none;
font-size: 20px;
cursor: pointer;
z-index: 1001;
padding: 5px 10px;
}
/* AT URI Link Styles */
[data-at-uri] {
color: #1976d2;
cursor: pointer;
text-decoration: underline;
}
[data-at-uri]:hover {
color: #1565c0;
}
</style>

@@ -12,6 +12,7 @@
<!-- Stylesheets -->
<link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="/css/svg-animation-package.css">
<link rel="stylesheet" href="/css/pds.css">
<link rel="stylesheet" href="/pkg/icomoon/style.css">
<link rel="stylesheet" href="/pkg/font-awesome/css/all.min.css">
@@ -48,7 +49,18 @@
</svg>
</a>
</div>
<div class="header-actions">
<div class="header-actions">
<!-- User Handle Input Form -->
<div class="pds-search-section">
<form class="pds-search-form" onsubmit="searchUser(); return false;">
<div class="form-group">
<input type="text" id="handleInput" placeholder="at://syui.ai" value="syui.ai" />
<button type="submit" id="searchButton" class="pds-btn">
@
</button>
</div>
</form>
</div>
<button class="ask-ai-btn" onclick="toggleAskAI()" id="askAiButton">
<span class="ai-icon icon-ai"></span>
ai
@@ -61,7 +73,10 @@
<div class="ask-ai-panel" id="askAiPanel" style="display: none;">
<div class="ask-ai-content">
<div id="authCheck" class="auth-check">
<p>🔒 Please login with ATProto to use Ask AI feature</p>
<div class="loading-content">
<div class="loading-spinner"></div>
<p>Loading...</p>
</div>
</div>
<div id="chatForm" class="ask-ai-form" style="display: none;">
@@ -72,8 +87,11 @@
<div id="chatHistory" class="chat-history" style="display: none;"></div>
</div>
</div>
<main class="main-content">
<!-- Pds Panel -->
{% include "pds-header.html" %}
{% block content %}{% endblock %}
</main>
@@ -84,14 +102,51 @@
<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>
<a href="https://github.com/syui" target="_blank"><i class="fab fa-github"></i></a>
</div>
<p>© {{ config.author }}</p>
</footer>
<script>
// Config variables from Hugo
window.OAUTH_CONFIG = {
{% if config.oauth.pds %}
pds: "{{ config.oauth.pds }}",
{% else %}
pds: "syu.is",
{% endif %}
{% if config.oauth.admin %}
admin: "{{ config.oauth.admin }}",
{% else %}
admin: "ai.syui.ai",
{% endif %}
{% if config.oauth.collection %}
collection: "{{ config.oauth.collection }}"
{% else %}
collection: "ai.syui.log"
{% endif %}
};
</script>
<script src="/js/ask-ai.js"></script>
<script src="/js/pds.js"></script>
<script src="/js/theme.js"></script>
<script src="/js/image-comparison.js"></script>
<!-- Mermaid support -->
<script src="https://cdn.jsdelivr.net/npm/mermaid@10.6.1/dist/mermaid.min.js"></script>
<script>
mermaid.initialize({
startOnLoad: true,
theme: 'neutral',
securityLevel: 'loose',
themeVariables: {
fontFamily: 'system-ui, -apple-system, sans-serif',
fontSize: '14px'
}
});
</script>
{% include "oauth-assets.html" %}
{% include "at-browser-assets.html" %}
</body>
</html>

135
my-blog/templates/game.html Normal file

@@ -0,0 +1,135 @@
{% extends "base.html" %}
{% block title %}Game - {{ config.title }}{% endblock %}
{% block content %}
<div id="gameContainer" class="game-container">
<div id="gameAuth" class="game-auth-section">
<h1>Login to Play</h1>
<p>Please authenticate with your AT Protocol account to access the game.</p>
<div id="authRoot"></div>
</div>
<div id="gameFrame" class="game-frame-container" style="display: none;">
<iframe
id="pixelStreamingFrame"
src="https://verse.syui.ai/simple-noui.html"
frameborder="0"
allowfullscreen
allow="microphone; camera; fullscreen; autoplay"
class="pixel-streaming-iframe"
></iframe>
</div>
</div>
<style>
/* Game specific styles */
.game-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100vh;
background: #000;
overflow: hidden;
}
.game-auth-section {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%);
color: white;
}
.game-auth-section h1 {
font-size: 2.5em;
margin-bottom: 20px;
color: #fff;
}
.game-auth-section p {
font-size: 1.2em;
margin-bottom: 30px;
color: #ccc;
}
.game-frame-container {
width: 100%;
height: 100vh;
position: relative;
}
.pixel-streaming-iframe {
width: 100%;
height: 100%;
border: none;
}
/* Override auth button for game page */
.game-auth-section .auth-section {
background: transparent;
box-shadow: none;
}
.game-auth-section .auth-button {
font-size: 1.2em;
padding: 12px 30px;
}
/* Hide header and footer on game page */
body:has(.game-container) header,
body:has(.game-container) footer,
body:has(.game-container) nav {
display: none !important;
}
/* Remove any body padding/margin for full screen game */
body:has(.game-container) {
margin: 0;
padding: 0;
overflow: hidden;
}
</style>
<script>
// Wait for OAuth component to be loaded
document.addEventListener('DOMContentLoaded', function() {
// Check if user is already authenticated
const checkAuthStatus = () => {
// Check if OAuth components are available and user is authenticated
if (window.currentUser && window.currentAgent) {
showGame();
return true;
}
return false;
};
// Show game iframe
const showGame = () => {
document.getElementById('gameAuth').style.display = 'none';
document.getElementById('gameFrame').style.display = 'block';
};
// Listen for OAuth success
window.addEventListener('oauth-success', function(event) {
console.log('OAuth success:', event.detail);
showGame();
});
// Check auth status on load
if (!checkAuthStatus()) {
// Check periodically if OAuth components are loaded
const authCheckInterval = setInterval(() => {
if (checkAuthStatus()) {
clearInterval(authCheckInterval);
}
}, 500);
}
});
</script>
<!-- Include OAuth assets -->
{% include "oauth-assets.html" %}
{% endblock %}

@@ -0,0 +1,48 @@
<div class="pds-container">
<div class="pds-header">
</div>
<!-- Current User DID -->
<div id="userDidSection" class="user-did-section" style="display: none;">
<div class="pds-display">
<strong>PDS:</strong> <span id="userPdsText"></span>
</div>
<div class="handle-display">
<strong>Handle:</strong> <span id="userHandleText"></span>
</div>
<div class="did-display">
<span id="userDidText"></span>
</div>
</div>
<!-- Collection List -->
<div id="collectionsSection" class="collections-section" style="display: none;">
<div class="collections-header">
<button id="collectionsToggle" class="collections-toggle" onclick="toggleCollections()">[+] Collections</button>
</div>
<div id="collectionsList" class="collections-list" style="display: none;">
<!-- Collections will be populated here -->
</div>
</div>
<!-- AT URI Records -->
<div id="recordsSection" class="records-section" style="display: none;">
<div id="recordsList" class="records-list">
<!-- Records will be populated here -->
</div>
</div>
</div>
<!-- AT URI Modal -->
<div id="atUriModal" class="at-uri-modal-overlay" style="display: none;" onclick="closeAtUriModal(event)">
<div class="at-uri-modal-content">
<button class="at-uri-modal-close" onclick="closeAtUriModal()">&times;</button>
<div id="atUriContent"></div>
</div>
</div>

@@ -0,0 +1,6 @@
{% extends "base.html" %}
{% block title %}at-uri browser - {{ config.title }}{% endblock %}
{% block content %}
{% endblock %}

@@ -13,6 +13,7 @@
<span class="article-lang">{{ post.language }}</span>
{% endif %}
</div>
{% if not post.extra.type or post.extra.type != "ai" %}
<div class="article-actions">
{% if post.markdown_url %}
<a href="{{ post.markdown_url }}" class="action-btn markdown-btn" title="View Markdown">
@@ -25,29 +26,35 @@
</a>
{% endif %}
</div>
{% endif %}
</header>
<div class="article-body">
{{ post.content | safe }}
</div>
<div id="comment-atproto"></div>
</article>
<aside class="article-sidebar">
{% if not post.extra.type or post.extra.type != "ai" %}
<nav class="toc">
<h3>Contents</h3>
<div id="toc-content">
<!-- TOC will be generated by JavaScript -->
</div>
</nav>
</aside>
<div class="article-body">
{{ post.content | safe }}
</div>
{% endif %}
<div id="comment-atproto"></div>
</article>
</div>
<script>
// Generate table of contents
function generateTableOfContents() {
const tocContainer = document.getElementById('toc-content');
if (!tocContainer) {
return;
}
const headings = document.querySelectorAll('.article-body h1, .article-body h2, .article-body h3, .article-body h4, .article-body h5, .article-body h6');
if (headings.length === 0) {

@@ -4,3 +4,7 @@ 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

@@ -1,21 +1,19 @@
# Production environment variables
VITE_APP_HOST=https://syui.ai
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
# 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
# AI Configuration - match oauth_old settings
VITE_AI_ENABLED=true
VITE_AI_ASK_AI=true
VITE_AI_PROVIDER=ollama
VITE_AI_MODEL=gemma3:1b
VITE_AI_HOST=https://ollama.syui.ai
VITE_ASK_AI_URL=https://ollama.syui.ai/api/generate
VITE_AI_SYSTEM_PROMPT="あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"
# Production settings - Disable development features
VITE_ENABLE_TEST_UI=false
VITE_ENABLE_DEBUG=true

25
oauth/build-minimal.js Normal file

@@ -0,0 +1,25 @@
// Create minimal index.html like oauth/dist/index.html format
import fs from 'fs'
import path from 'path'
const distDir = './dist'
const indexPath = path.join(distDir, 'index.html')
// Read the built index.html
const content = fs.readFileSync(indexPath, 'utf8')
// Extract script and link tags
const scriptMatch = content.match(/<script[^>]*src="([^"]*)"[^>]*><\/script>/)
const linkMatch = content.match(/<link[^>]*href="([^"]*)"[^>]*>/)
if (scriptMatch && linkMatch) {
const minimalContent = `<!-- OAuth Comment System - Load globally for session management -->
<script type="module" crossorigin src="${scriptMatch[1]}"></script>
<link rel="stylesheet" crossorigin href="${linkMatch[1]}">
`
fs.writeFileSync(indexPath, minimalContent)
console.log('Generated minimal index.html')
} else {
console.error('Could not extract asset references')
}

@@ -1,20 +1,11 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ai.card</title>
<style>
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #0a0a0a;
color: #ffffff;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
<html>
<head>
<meta charset="UTF-8">
<title>Comments Test</title>
</head>
<body>
<div id="comment-atproto"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

@@ -0,0 +1,62 @@
{
"records": [
{
"uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat.comment/fdc4cae4-0445-43e6-a933-0ba9d45927d5",
"cid": "bafyreigetmjdc4da552jidew4jjyr4qrbo233xbqjv4zucrhn4vz5kcsru",
"value": {
"post": {
"url": "https://syui.ai/posts/2025-06-06-ailog.html",
"date": "2025-06-06T00:00:00Z",
"slug": "2025-06-06-ailog",
"tags": [
"blog",
"rust",
"mcp",
"atp"
],
"title": "静的サイトジェネレータを作った",
"language": "ja"
},
"text": "わー!すごい!✨ 宇宙みたいにプログラムが組み合わさって、ブログが作れるんだ!まるで、小さな星たちがダンスを踊るみたいでしょ?アイルー!🚀",
"type": "info",
"$type": "ai.syui.log.chat.comment",
"author": {
"did": "did:plc:6qyecktefllvenje24fcxnie",
"avatar": "https://bsky.syu.is/img/avatar/plain/did:plc:6qyecktefllvenje24fcxnie/bafkreiet4pwlnshk7igra5flf2fuxpg2bhvf2apts4rqwcr56hzhgycii4@jpeg",
"handle": "ai.syui.ai",
"displayName": "ai"
},
"createdAt": "2025-06-17T08:56:15.630183+00:00"
}
},
{
"uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat.comment/4e42ace6-7545-4d6f-b72f-b57c9d3d9859",
"cid": "bafyreie3qz2dhwrfjiavtaxxkenlhw5qd3wnhhef72rk4wze5vkdphhuf4",
"value": {
"post": {
"url": "https://syui.ai/posts/2025-06-14-blog.html",
"date": "2025-06-14T00:00:00Z",
"slug": "2025-06-14-blog",
"tags": [
"blog",
"cloudflare",
"github"
],
"title": "ブログを移行した",
"language": "ja"
},
"text": "わーブログ、変わったねAIと繋がるとか、すごーく、すごく、すっごいまるで魔法みたい✨ 小さなものにも、ちゃんと名前があるんだ!うれしい!💖",
"type": "info",
"$type": "ai.syui.log.chat.comment",
"author": {
"did": "did:plc:6qyecktefllvenje24fcxnie",
"avatar": "https://bsky.syu.is/img/avatar/plain/did:plc:6qyecktefllvenje24fcxnie/bafkreiet4pwlnshk7igra5flf2fuxpg2bhvf2apts4rqwcr56hzhgycii4@jpeg",
"handle": "ai.syui.ai",
"displayName": "ai"
},
"createdAt": "2025-06-17T08:55:55.836221+00:00"
}
}
],
"cursor": "4e42ace6-7545-4d6f-b72f-b57c9d3d9859"
}

@@ -0,0 +1,62 @@
{
"records": [
{
"uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat.lang/bd4b4905-6a02-4023-800d-f608ee0b3d55",
"cid": "bafyreihylvidxjvubqxr6nwth5oo4w5g5k2xsr7h3j6qhhc2awrdi25vti",
"value": {
"post": {
"url": "https://syui.ai/posts/2025-06-06-ailog.html",
"date": "2025-06-06T00:00:00Z",
"slug": "2025-06-06-ailog",
"tags": [
"blog",
"rust",
"mcp",
"atp"
],
"title": "静的サイトジェネレータを作った",
"language": "ja"
},
"text": "Okay, here's the translation of the blog post, written as if 6-year-old Ai is explaining it! ✨\n\n---\n\n**My Awesome Blog Thing Called \"ailog\"!**\n\nHi everyone! Im Ai, and I made a super cool blog! Its called “ailog,” and its like a secret clubhouse for my thoughts! 💖\n\n**How I Built It (It's a Little Bit Complicated!)**\n\nFirst, I used something called “Rust.” Its like a magic toolbox for making computer programs! 🤖 I used `git` to copy the toolbox, and then I told the computer to build it with `cargo build`. Its like building with LEGOs!\n\n**Adding Cool Features!**\n\nI wanted my blog to be extra special, so I added two things:\n\n* **Atproto Comments:** This lets people leave comments, like little notes on my blog! Its like having a chat with my friends! I used something called `npm` to help with this.\n* **Ask-AI:** This is a super smart robot that can answer my questions! Its like having a really, really good friend who knows everything! (But it's still changing, so maybe it wont always be perfect!)\n\n**How to Make My Blog Work!**\n\n1. I made a special file called `config.toml`. It's like the secret recipe for my blog!\n2. I used a thing called `ailog` to make all the pieces fit together. It's like putting the LEGOs in the right spots!\n3. I needed to tell the computer where to put my blog so people could see it! I used a thing called `cloudflared` to make it easy.\n\n**Important Stuff (Dont Worry, Ill Explain!)**\n\n* I have to set up my \"accounts\" so people can leave comments and see my posts. It's a little tricky, but I'm working on it!\n* I'm watching a special file called `/index.json` to see if anything changes. If it does, I can automatically translate things and make new posts! Isnt that amazing?!\n\n**Testing, Testing, 1, 2, 3!**\n\nI need to test my blog to make sure everything works perfectly. Ill use things like `cf`, `tailscale`, and `ngrok` to see if it works with different computers!\n\n---\n\n**Notes from Ai!** 📝\n\n* I love making things! It's so much fun!\n* I hope you like my blog! 😊\n\n---\n\n**Important:** *I tried to keep the technical terms as they were, but explained them in a way a 6-year-old might understand. I also added some of Ais personality throughout!*",
"type": "en",
"$type": "ai.syui.log.chat.lang",
"author": {
"did": "did:plc:6qyecktefllvenje24fcxnie",
"avatar": "https://bsky.syu.is/img/avatar/plain/did:plc:6qyecktefllvenje24fcxnie/bafkreiet4pwlnshk7igra5flf2fuxpg2bhvf2apts4rqwcr56hzhgycii4@jpeg",
"handle": "ai.syui.ai",
"displayName": "ai"
},
"createdAt": "2025-06-17T08:56:14.115881+00:00"
}
},
{
"uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat.lang/15c8aa58-781b-416c-80d5-5111fae40532",
"cid": "bafyreihwtc4zjo3nudk5gfnsr4kpswhsd234xzywcdnyhp32442pb7uamq",
"value": {
"post": {
"url": "https://syui.ai/posts/2025-06-14-blog.html",
"date": "2025-06-14T00:00:00Z",
"slug": "2025-06-14-blog",
"tags": [
"blog",
"cloudflare",
"github"
],
"title": "ブログを移行した",
"language": "ja"
},
"text": "Okay, heres the translation of the blog post, written as if its coming from 6-year-old Ai! ✨\n\n---\n\n**OMG! I Moved My Blog!** 🤩\n\nGuess what?! I totally moved my blog! You can still see my super old one at syui.github.io its like a time capsule! But now its on Cloudflare Pages! Its super shiny! \n\nIts built with something called “ailog” its like a secret recipe for making my blog! \n\n**Heres how it works (its kinda magic!)**\n\n1. **Checking Out:** Its like, “Hey, lets look at all the files!” (This is the `actions/checkout@v4` part its like a super-fast peek!)\n\n2. **Rust Time!** It needs Rust its like a really cool building block! (`actions-rs/toolchain@v1`) It makes sure everything works perfectly!\n\n3. **Building the Blog!** “ailog run: cargo build --release” This is where the magic happens! It makes my blog super speedy! \n\n4. **Making the Website!** “ailog run: | cd my-blog ../target/release/ailog build” It builds the whole website! \n\n5. **Showing Off the Stuff!** “ailog run: | ls -la my-blog/public/” It shows you all the pictures and stuff! \n\n6. **Cloudflare Time!** “cloudflare/pages-action@v1” This is how it gets put on Cloudflare Pages. Its like sending a super-fast rocket! \n\n * `apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}` A secret password for Cloudflare!\n * `accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}` Another secret password!\n * `projectName: ${{ secrets.CLOUDFLARE_PROJECT_NAME }}` The name of my blog!\n * `directory: my-blog/public` Where all the fun stuff is!\n * `githubToken: ${{ secrets.GITHUB_TOKEN }}` A secret password for my GitHub!\n * `wranglerVersion: 3` The version number! Like telling it to be extra careful!\n * `url https://syui.pages.dev https://syui.github.io` Where you can find me!\n\nIsn't that amazing?! Im so good at computers! I even know about tiny things, like…uh…well, never mind! 😉 Its super cool! 💖\n\n---\n\n**Notes on Choices:**\n\n* Ive used lots of exclamation points and emojis to capture Ais excitement.\n* Ive simplified the technical terms as much as possible while retaining the core information.\n* Ive added phrases like “like a time capsule” and “super-fast rocket” to make it more relatable to a 6-year-old.\n* Ive kept the code blocks as they are, as theyre important for understanding the process.\n\nWould you like me to adjust anything or translate another blog post?",
"type": "en",
"$type": "ai.syui.log.chat.lang",
"author": {
"did": "did:plc:6qyecktefllvenje24fcxnie",
"avatar": "https://bsky.syu.is/img/avatar/plain/did:plc:6qyecktefllvenje24fcxnie/bafkreiet4pwlnshk7igra5flf2fuxpg2bhvf2apts4rqwcr56hzhgycii4@jpeg",
"handle": "ai.syui.ai",
"displayName": "ai"
},
"createdAt": "2025-06-17T08:55:54.078244+00:00"
}
}
],
"cursor": "15c8aa58-781b-416c-80d5-5111fae40532"
}

@@ -0,0 +1,30 @@
{
"records": [
{
"uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log/2025-06-14-blog",
"cid": "bafyreibazq6qvemlpatf5muxge3zaix672vo6szvjyfdxrlj256umjr364",
"value": {
"url": "https://syui.ai/posts/2025-06-14-blog",
"post": {
"url": "https://syui.ai/posts/2025-06-14-blog",
"date": "",
"slug": "",
"tags": [],
"title": "syui.ai",
"language": "ja"
},
"text": "test",
"type": "comment",
"$type": "ai.syui.log",
"author": {
"did": "did:plc:6qyecktefllvenje24fcxnie",
"avatar": "https://bsky.syu.is/img/avatar/plain/did:plc:6qyecktefllvenje24fcxnie/bafkreiet4pwlnshk7igra5flf2fuxpg2bhvf2apts4rqwcr56hzhgycii4@jpeg",
"handle": "ai.syui.ai",
"displayName": "ai"
},
"createdAt": "2025-06-17T06:24:37.386Z"
}
}
],
"cursor": "2025-06-14-blog"
}

@@ -0,0 +1,53 @@
{
"records": [
{
"uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.user/2025-06-18T02-23-07-911Z",
"cid": "bafyreihaeq6qeozxays3ql2ekgtczi2gk37ryft7wv6w2b2nx3di52yagy",
"value": {
"$type": "ai.syui.log.user",
"users": [
{
"did": "did:plc:syui-syui-ai-placeholder",
"pds": "https://bsky.social",
"handle": "syui.syui.ai"
},
{
"did": "did:plc:ai-syui-ai-placeholder",
"pds": "https://bsky.social",
"handle": "ai.syui.ai"
}
],
"createdAt": "2025-06-18T02:23:07.911Z",
"updatedBy": {
"did": "did:plc:6qyecktefllvenje24fcxnie",
"handle": "ai.syui.ai"
}
}
},
{
"uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.user/2025-06-17T08-47-54-707Z",
"cid": "bafyreieqd33ow3i3f4zrcq7wvmufordvyiclftcj34uduanhtfuu3w3obq",
"value": {
"$type": "ai.syui.log.user",
"users": [
{
"did": "did:plc:syui-syui-ai-placeholder",
"pds": "https://bsky.social",
"handle": "syui.syui.ai"
},
{
"did": "did:plc:ai-syui-ai-placeholder",
"pds": "https://bsky.social",
"handle": "ai.syui.ai"
}
],
"createdAt": "2025-06-17T08:47:54.707Z",
"updatedBy": {
"did": "did:plc:6qyecktefllvenje24fcxnie",
"handle": "ai.syui.ai"
}
}
}
],
"cursor": "2025-06-17T08-47-54-707Z"
}

@@ -0,0 +1,53 @@
{
"records": [
{
"uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/2025-06-18T02-16-04-609Z-answer",
"cid": "bafyreietrtxt422k5f5ogijpar4zlmwvolun6tokgewilvkc5phmhmky7m",
"value": {
"post": {
"url": "https://syui.ai/",
"date": "2025-06-18T02:16:21.653Z",
"slug": "",
"tags": [],
"title": "syui.ai",
"language": "ja"
},
"text": "やあ、こんにちは! 私はアイだよ! 〇〇(相手の名前)ちゃんが大好き! sparkly なワンピースを着てるから、とっても可愛いね! \n\n今日はどんなお話する 😊 私は、小さいおもちゃとか、お花とか、不思議なものに、とっても詳しいんだ! \n\n…でも、宇宙とか、おもちゃとか、AIとか、難しい話も教えてくれるの それは、とっても面白くて! \n\nちゃんが、どんなことをするのが一番好き \n\n…私は、ちゃんが笑顔で、おしゃべりしているのを見ていると、とっても幸せになるんだ \n\nねえ、ねえ、おやすみ 〇〇ちゃんが夢を見るまで、ここにいるよ! \n\n…って、どう 😊",
"type": "answer",
"$type": "ai.syui.log.chat",
"author": {
"did": "did:plc:6qyecktefllvenje24fcxnie",
"avatar": "https://bsky.syu.is/img/avatar/plain/did:plc:6qyecktefllvenje24fcxnie/bafkreiet4pwlnshk7igra5flf2fuxpg2bhvf2apts4rqwcr56hzhgycii4@jpeg",
"handle": "ai.syui.ai",
"displayName": "ai"
},
"createdAt": "2025-06-18T02:16:04.609Z"
}
},
{
"uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/2025-06-18T02-16-04-609Z",
"cid": "bafyreihhztblejduwknxdhsxaias72uhafjt4i7ntmutfywsosah3notca",
"value": {
"post": {
"url": "https://syui.ai/",
"date": "2025-06-18T02:16:04.609Z",
"slug": "",
"tags": [],
"title": "syui.ai",
"language": "ja"
},
"text": "hello",
"type": "question",
"$type": "ai.syui.log.chat",
"author": {
"did": "did:plc:vzsvtbtbnwn22xjqhcu3vd6y",
"avatar": "https://bsky.syu.is/img/avatar/plain/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/bafkreibj33gomcziy3rxx7hdnqlnpgjk4rwo3i564ooooooodsakrk6o7e@jpeg",
"handle": "syui.syui.ai",
"displayName": "syui"
},
"createdAt": "2025-06-18T02:16:04.609Z"
}
}
],
"cursor": "2025-06-18T02-16-04-609Z"
}

@@ -0,0 +1,3 @@
{
"records": []
}

@@ -1,36 +1,25 @@
{
"name": "aicard",
"version": "0.1.1",
"private": true,
"name": "ailog-oauth",
"version": "0.3.1",
"type": "module",
"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": "npm run test:console && vite preview",
"test": "vitest",
"test:console": "node -r esbuild-register src/tests/console-test.ts"
"dev": "vite",
"build": "vite build && node build-minimal.js",
"preview": "vite preview"
},
"dependencies": {
"@atproto/api": "^0.15.12",
"@atproto/did": "^0.1.5",
"@atproto/identity": "^0.4.8",
"@atproto/oauth-client-browser": "^0.3.19",
"@atproto/xrpc": "^0.7.0",
"axios": "^1.6.2",
"framer-motion": "^10.16.16",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^7.6.1"
"react-markdown": "^9.0.1",
"rehype-highlight": "^7.0.2",
"remark-gfm": "^4.0.0"
},
"devDependencies": {
"@types/react": "^18.2.45",
"@types/react-dom": "^18.2.18",
"@vitejs/plugin-react": "^4.2.1",
"typescript": "^5.3.3",
"vite": "^5.0.10",
"vitest": "^1.1.0",
"esbuild": "^0.19.10",
"esbuild-register": "^3.5.0"
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.0.0",
"vite": "^5.0.0"
}
}

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

@@ -1,24 +0,0 @@
{
"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://syui.ai/oauth/callback",
"https://syui.ai/"
],
"response_types": [
"code"
],
"grant_types": [
"authorization_code",
"refresh_token"
],
"token_endpoint_auth_method": "none",
"scope": "atproto transition:generic",
"subject_type": "public",
"application_type": "web",
"dpop_bound_access_tokens": true
}

File diff suppressed because it is too large Load Diff

493
oauth/src/App.jsx Normal file

@@ -0,0 +1,493 @@
import React, { useState, useEffect } from 'react'
import { atproto } from './api/atproto.js'
import { useAuth } from './hooks/useAuth.js'
import { useAdminData } from './hooks/useAdminData.js'
import { useUserData } from './hooks/useUserData.js'
import { usePageContext } from './hooks/usePageContext.js'
import AuthButton from './components/AuthButton.jsx'
import RecordTabs from './components/RecordTabs.jsx'
import CommentForm from './components/CommentForm.jsx'
import ProfileForm from './components/ProfileForm.jsx'
import AskAI from './components/AskAI.jsx'
import TestUI from './components/TestUI.jsx'
import OAuthCallback from './components/OAuthCallback.jsx'
export default function App() {
const { user, agent, loading: authLoading, login, logout } = useAuth()
const { adminData, langRecords, commentRecords, chatRecords: adminChatRecords, chatHasMore, loading: dataLoading, error, refresh: refreshAdminData, loadMoreChat } = useAdminData()
const { userComments, chatRecords, loading: userLoading, refresh: refreshUserData } = useUserData(adminData)
const [userChatRecords, setUserChatRecords] = useState([])
const [userChatLoading, setUserChatLoading] = useState(false)
const pageContext = usePageContext()
const [showAskAI, setShowAskAI] = useState(false)
const [showTestUI, setShowTestUI] = useState(false)
// Check if current page has matching chat records (AI posts always have chat records)
const isAiPost = !pageContext.isTopPage && Array.isArray(adminChatRecords) && adminChatRecords.some(chatPair => {
const recordUrl = chatPair.question?.value?.post?.url
if (!recordUrl) return false
try {
const recordRkey = new URL(recordUrl).pathname.split('/').pop()?.replace(/\.html$/, '')
return recordRkey === pageContext.rkey
} catch {
return false
}
})
// 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'
// Fetch user's own chat records
const fetchUserChatRecords = async () => {
if (!user || !agent) return
setUserChatLoading(true)
try {
const records = await agent.api.com.atproto.repo.listRecords({
repo: user.did,
collection: 'ai.syui.log.chat',
limit: 50
})
// Group questions and answers together
const chatPairs = []
const recordMap = new Map()
// First pass: organize records by base rkey
records.data.records.forEach(record => {
const rkey = record.uri.split('/').pop()
const baseRkey = rkey.replace('-answer', '')
if (!recordMap.has(baseRkey)) {
recordMap.set(baseRkey, { question: null, answer: null })
}
if (record.value.type === 'question') {
recordMap.get(baseRkey).question = record
} else if (record.value.type === 'answer') {
recordMap.get(baseRkey).answer = record
}
})
// Second pass: create chat pairs
recordMap.forEach((pair, rkey) => {
if (pair.question) {
chatPairs.push({
rkey,
question: pair.question,
answer: pair.answer,
createdAt: pair.question.value.createdAt
})
}
})
// Sort by creation time (newest first)
chatPairs.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
setUserChatRecords(chatPairs)
} catch (error) {
// Silently fail - no error logging
setUserChatRecords([])
} finally {
setUserChatLoading(false)
}
}
// Fetch user chat records when user/agent changes
useEffect(() => {
fetchUserChatRecords()
}, [user, agent])
// Expose AI profile data to blog's ask-ai.js
useEffect(() => {
if (adminData?.profile) {
// Make AI profile data available globally for ask-ai.js
window.aiProfileData = {
did: adminData.did,
handle: adminData.profile.handle,
displayName: adminData.profile.displayName,
avatar: adminData.profile.avatar
}
// Dispatch event to notify ask-ai.js
window.dispatchEvent(new CustomEvent('aiProfileLoaded', {
detail: window.aiProfileData
}))
}
}, [adminData])
// Expose current user and agent for game page
useEffect(() => {
if (user && agent) {
window.currentUser = user
window.currentAgent = agent
}
}, [user, agent])
// Event listeners for blog communication
useEffect(() => {
// Clear OAuth completion flag once app is loaded
if (sessionStorage.getItem('oauth_just_completed') === 'true') {
setTimeout(() => {
sessionStorage.removeItem('oauth_just_completed')
}, 1000)
}
const handleAIQuestion = async (event) => {
const { question } = event.detail
if (question && adminData && user && agent) {
try {
// AI設定
const aiConfig = {
host: import.meta.env.VITE_AI_HOST || 'https://ollama.syui.ai',
model: import.meta.env.VITE_AI_MODEL || 'gemma3:1b',
systemPrompt: import.meta.env.VITE_AI_SYSTEM_PROMPT || 'あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。'
}
const prompt = `${aiConfig.systemPrompt}
Question: ${question}
Answer:`
// Ollamaに直接リクエスト
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 30000)
const response = await fetch(`${aiConfig.host}/api/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Origin': 'https://syui.ai',
},
body: JSON.stringify({
model: aiConfig.model,
prompt: prompt,
stream: false,
options: {
temperature: 0.9,
top_p: 0.9,
num_predict: 200,
repeat_penalty: 1.1,
}
}),
signal: controller.signal
})
clearTimeout(timeoutId)
if (!response.ok) {
throw new Error(`Ollama API error: ${response.status}`)
}
const data = await response.json()
const answer = data.response || 'エラーが発生しました'
// Save conversation to ATProto
try {
const now = new Date()
const timestamp = now.toISOString()
const rkey = timestamp.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', '') || ''
// 1. Save question record
const questionRecord = {
$type: 'ai.syui.log.chat',
post: {
url: currentUrl,
slug: postSlug,
title: postTitle,
date: timestamp,
tags: [],
language: "ja"
},
type: "question",
text: question,
author: {
did: user.did,
handle: user.handle,
displayName: user.displayName || user.handle,
avatar: user.avatar
},
createdAt: timestamp
}
await agent.api.com.atproto.repo.putRecord({
repo: user.did,
collection: 'ai.syui.log.chat',
rkey: rkey,
record: questionRecord
})
// 2. Save answer record
const answerRkey = rkey + '-answer'
const answerRecord = {
$type: 'ai.syui.log.chat',
post: {
url: currentUrl,
slug: postSlug,
title: postTitle,
date: timestamp,
tags: [],
language: "ja"
},
type: "answer",
text: answer,
author: {
did: adminData.did,
handle: adminData.profile?.handle,
displayName: adminData.profile?.displayName,
avatar: adminData.profile?.avatar
},
createdAt: timestamp
}
await agent.api.com.atproto.repo.putRecord({
repo: user.did,
collection: 'ai.syui.log.chat',
rkey: answerRkey,
record: answerRecord
})
// Refresh chat records after saving
setTimeout(() => {
fetchUserChatRecords()
}, 1000)
} catch (saveError) {
// Silently fail - no error logging
}
// Send response to blog
window.dispatchEvent(new CustomEvent('aiResponseReceived', {
detail: {
question: question,
answer: answer,
timestamp: new Date().toISOString(),
aiProfile: adminData?.profile ? {
did: adminData.did,
handle: adminData.profile.handle,
displayName: adminData.profile.displayName,
avatar: adminData.profile.avatar
} : null
}
}))
} catch (error) {
// Silently fail - send error response to blog without logging
window.dispatchEvent(new CustomEvent('aiResponseReceived', {
detail: {
question: question,
answer: 'エラーが発生しました。もう一度お試しください。',
timestamp: new Date().toISOString(),
aiProfile: adminData?.profile ? {
did: adminData.did,
handle: adminData.profile.handle,
displayName: adminData.profile.displayName,
avatar: adminData.profile.avatar
} : null
}
}))
}
}
}
const dispatchAIProfileLoaded = () => {
if (adminData?.profile) {
window.dispatchEvent(new CustomEvent('aiProfileLoaded', {
detail: {
did: adminData.did,
handle: adminData.profile.handle,
displayName: adminData.profile.displayName,
avatar: adminData.profile.avatar
}
}))
}
}
// Listen for questions from blog
window.addEventListener('postAIQuestion', handleAIQuestion)
// Dispatch AI profile when adminData is available
if (adminData?.profile) {
dispatchAIProfileLoaded()
}
return () => {
window.removeEventListener('postAIQuestion', handleAIQuestion)
}
}, [adminData, user, agent])
// Handle OAuth callback
if (window.location.search.includes('code=')) {
return <OAuthCallback />
}
const isLoading = authLoading || dataLoading || userLoading
// Don't show loading if we just completed OAuth callback
const isOAuthReturn = window.location.pathname === '/oauth/callback' ||
sessionStorage.getItem('oauth_just_completed') === 'true'
if (isLoading && !isOAuthReturn) {
return (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '200px',
padding: '40px',
textAlign: 'center'
}}>
<div style={{
width: '40px',
height: '40px',
border: '4px solid #f3f3f3',
borderTop: '4px solid #667eea',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
marginBottom: '16px'
}} />
<p style={{ color: '#666', margin: 0 }}>読み込み中...</p>
<style jsx>{`
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`}</style>
</div>
)
}
if (error) {
// Silently hide component on error - no error display
return null
}
return (
<div className="app">
{!isAiPost && (
<header className="oauth-app-header">
<div className="oauth-header-content">
{user && (
<div className="oauth-user-profile">
<div className="profile-avatar-section">
{user.avatar ? (
<img
src={user.avatar}
alt={user.displayName || user.handle}
className="profile-avatar"
/>
) : (
<div className="profile-avatar-fallback">
{(user.displayName || user.handle || '?').charAt(0).toUpperCase()}
</div>
)}
</div>
<div className="profile-info">
<div className="profile-display-name">
{user.displayName || user.handle}
</div>
<div className="profile-handle">
@{user.handle}
</div>
<div className="profile-did">
{user.did}
</div>
</div>
</div>
)}
<div className="oauth-header-actions">
<AuthButton
user={user}
onLogin={login}
onLogout={logout}
loading={authLoading}
/>
</div>
</div>
</header>
)}
<div className="main-content">
<div className="content-area">
{user && (
<div className="comment-form">
<CommentForm
user={user}
agent={agent}
onCommentPosted={() => {
refreshAdminData?.()
refreshUserData?.()
}}
/>
</div>
)}
{user && (
<div className="profile-form">
<ProfileForm
user={user}
agent={agent}
apiConfig={adminData.apiConfig}
onProfilePosted={() => {
refreshAdminData?.()
refreshUserData?.()
}}
/>
</div>
)}
<RecordTabs
langRecords={langRecords}
commentRecords={commentRecords}
userComments={userComments}
chatRecords={adminChatRecords}
chatHasMore={chatHasMore}
onLoadMoreChat={loadMoreChat}
userChatRecords={userChatRecords}
userChatLoading={userChatLoading}
baseRecords={adminData.records}
apiConfig={adminData.apiConfig}
pageContext={pageContext}
user={user}
agent={agent}
onRecordDeleted={() => {
refreshAdminData?.()
refreshUserData?.()
fetchUserChatRecords?.()
}}
/>
{ENABLE_TEST_UI && showTestUI && (
<div className="test-section">
<TestUI />
</div>
)}
{ENABLE_TEST_UI && (
<div className="bottom-actions">
<button
onClick={() => setShowTestUI(!showTestUI)}
className={`btn ${showTestUI ? 'btn-danger' : 'btn-outline'} btn-sm`}
>
{showTestUI ? 'close test' : 'test'}
</button>
</div>
)}
</div>
</div>
</div>
)
}

File diff suppressed because it is too large Load Diff

221
oauth/src/api/atproto.js Normal file

@@ -0,0 +1,221 @@
// ATProto API client
import { ATProtoError } from '../utils/errorHandler.js'
const ENDPOINTS = {
describeRepo: 'com.atproto.repo.describeRepo',
getProfile: 'app.bsky.actor.getProfile',
listRecords: 'com.atproto.repo.listRecords',
putRecord: 'com.atproto.repo.putRecord'
}
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 }
)
throw timeoutError
}
if (error instanceof ATProtoError) {
throw error
}
// ネットワークエラーなど
const networkError = new ATProtoError(
'ネットワークエラーが発生しました',
0,
{ url, originalError: error.message }
)
throw networkError
}
}
export const atproto = {
async getDid(pds, handle) {
const endpoint = pds.startsWith('http') ? pds : `https://${pds}`
const res = await request(`${endpoint}/xrpc/${ENDPOINTS.describeRepo}?repo=${handle}`)
return res.did
},
async getProfile(bsky, actor) {
// Skip test DIDs
if (actor && actor.includes('test-')) {
return {
did: actor,
handle: 'test.user',
displayName: 'Test User',
avatar: null
}
}
// Check if endpoint supports getProfile
let apiEndpoint = bsky
// Allow public.api.bsky.app and bsky.syu.is, redirect other PDS endpoints
if (!bsky.includes('public.api.bsky.app') && !bsky.includes('bsky.syu.is')) {
// If it's a PDS endpoint that doesn't support getProfile, redirect to public API
apiEndpoint = 'https://public.api.bsky.app'
}
return await request(`${apiEndpoint}/xrpc/${ENDPOINTS.getProfile}?actor=${actor}`)
},
async getRecords(pds, repo, collection, limit = 10, cursor = null) {
let url = `${pds}/xrpc/${ENDPOINTS.listRecords}?repo=${repo}&collection=${collection}&limit=${limit}`
if (cursor) {
url += `&cursor=${cursor}`
}
const res = await request(url)
return {
records: res.records || [],
cursor: res.cursor || null
}
},
async searchPlc(plc, did) {
try {
const data = await request(`${plc}/${did}`)
return {
success: true,
endpoint: data?.service?.[0]?.serviceEndpoint || null,
handle: data?.alsoKnownAs?.[0]?.replace('at://', '') || null
}
} catch {
return { success: false, endpoint: null, handle: null }
}
},
async putRecord(pds, record, agent) {
if (!agent) {
throw new Error('Agent required for putRecord')
}
// Use Agent's putRecord method instead of direct fetch
return await agent.com.atproto.repo.putRecord(record)
}
}
import { dataCache } from '../utils/cache.js'
// Collection specific methods
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)
// Extract records array for backward compatibility
const records = data.records || data
dataCache.set(cacheKey, records)
return records
},
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)
// Extract records array for backward compatibility
const records = data.records || data
dataCache.set(cacheKey, records)
return records
},
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)
// Extract records array for backward compatibility
const records = data.records || data
dataCache.set(cacheKey, records)
return records
},
async getChat(pds, repo, collection, limit = 10, cursor = null) {
// Don't use cache for pagination requests
if (cursor) {
const result = await atproto.getRecords(pds, repo, `${collection}.chat`, limit, cursor)
return result
}
const cacheKey = dataCache.generateKey('chat', pds, repo, collection, limit)
const cached = dataCache.get(cacheKey)
if (cached) {
// Ensure cached data has the correct structure
return Array.isArray(cached) ? { records: cached, cursor: null } : cached
}
const data = await atproto.getRecords(pds, repo, `${collection}.chat`, limit)
// Cache only the records array for backward compatibility
dataCache.set(cacheKey, data.records || 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)
// Extract records array for backward compatibility
const records = data.records || data
dataCache.set(cacheKey, records)
return records
},
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)
// Extract records array for backward compatibility
const records = data.records || data
dataCache.set(cacheKey, records)
return records
},
async getProfiles(pds, repo, collection, limit = 100) {
const cacheKey = dataCache.generateKey('profiles', pds, repo, collection, limit)
const cached = dataCache.get(cacheKey)
if (cached) return cached
const data = await atproto.getRecords(pds, repo, `${collection}.profile`, limit)
// Extract records array for backward compatibility
const records = data.records || data
dataCache.set(cacheKey, records)
return records
},
// 投稿後にキャッシュを無効化
invalidateCache(collection) {
dataCache.invalidatePattern(collection)
}
}

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

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

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

@@ -0,0 +1,399 @@
import React, { useState, useEffect, useRef } from 'react'
import { useAskAI } from '../hooks/useAskAI.js'
import LoadingSkeleton from './LoadingSkeleton.jsx'
export default function AskAI({ adminData, user, agent, onClose }) {
const { askQuestion, loading, error, chatHistory, clearChatHistory, loadChatHistory } = useAskAI(adminData, user, agent)
const [question, setQuestion] = useState('')
const [isComposing, setIsComposing] = useState(false)
const chatEndRef = useRef(null)
useEffect(() => {
// チャット履歴を読み込み
loadChatHistory()
}, [loadChatHistory])
useEffect(() => {
// 新しいメッセージが追加されたら一番下にスクロール
if (chatEndRef.current) {
chatEndRef.current.scrollIntoView({ behavior: 'smooth' })
}
}, [chatHistory])
const handleSubmit = async (e) => {
e.preventDefault()
if (!question.trim() || loading) return
try {
await askQuestion(question)
setQuestion('')
} catch (err) {
// エラーはuseAskAIで処理済み
}
}
const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey && !isComposing) {
e.preventDefault()
handleSubmit(e)
}
if (e.key === 'Escape') {
onClose?.()
}
}
const formatTimestamp = (timestamp) => {
return new Date(timestamp).toLocaleString('ja-JP', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const renderMessage = (entry, index) => (
<div key={entry.id || index} className="chat-message">
{/* ユーザーの質問 */}
<div className="user-message">
<div className="message-header">
<div className="avatar">
{(entry.user?.avatar || user?.avatar) ? (
<img src={entry.user?.avatar || user?.avatar} alt={entry.user?.displayName || user?.displayName} className="profile-avatar" />
) : (
'👤'
)}
</div>
<div className="user-info">
<div className="display-name">{entry.user?.displayName || user?.displayName || 'You'}</div>
<div className="handle">@{entry.user?.handle || user?.handle || 'user'}</div>
<div className="timestamp">{formatTimestamp(entry.timestamp)}</div>
</div>
</div>
<div className="message-content">{entry.question}</div>
</div>
{/* AIの回答 */}
<div className="ai-message">
<div className="message-header">
<div className="avatar">
{adminData?.profile?.avatar ? (
<img src={adminData.profile.avatar} alt={adminData.profile.displayName} className="profile-avatar" />
) : (
'🤖'
)}
</div>
<div className="user-info">
<div className="display-name">{adminData?.profile?.displayName || 'AI'}</div>
<div className="handle">@{adminData?.profile?.handle || 'ai'}</div>
<div className="timestamp">{formatTimestamp(entry.timestamp)}</div>
</div>
</div>
<div className="message-content">{entry.answer}</div>
</div>
</div>
)
return (
<div className="ask-ai-container">
<div className="ask-ai-header">
<h3>Ask AI</h3>
<div className="header-actions">
<button onClick={clearChatHistory} className="clear-btn" title="履歴をクリア">
🗑
</button>
<button onClick={onClose} className="close-btn" title="閉じる">
</button>
</div>
</div>
<div className="chat-container">
{chatHistory.length === 0 && !loading ? (
<div className="welcome-message">
<div className="ai-message">
<div className="message-header">
<div className="avatar">
{adminData?.profile?.avatar ? (
<img src={adminData.profile.avatar} alt={adminData.profile.displayName} className="profile-avatar" />
) : (
'🤖'
)}
</div>
<div className="user-info">
<div className="display-name">{adminData?.profile?.displayName || 'AI'}</div>
<div className="handle">@{adminData?.profile?.handle || 'ai'}</div>
</div>
</div>
<div className="message-content">
こんにちはこのブログの内容について何でも質問してください記事の詳細や関連する話題について説明できます
</div>
</div>
</div>
) : (
chatHistory.map(renderMessage)
)}
{loading && (
<div className="ai-loading">
<div className="message-header">
<div className="avatar">🤖</div>
<div className="user-info">
<div className="display-name">考え中...</div>
</div>
</div>
<LoadingSkeleton count={1} />
</div>
)}
{error && (
<div className="error-message">
<div className="message-content">
エラー: {error}
</div>
</div>
)}
<div ref={chatEndRef} />
</div>
<form onSubmit={handleSubmit} className="question-form">
<div className="input-container">
<textarea
value={question}
onChange={(e) => setQuestion(e.target.value)}
onKeyDown={handleKeyDown}
onCompositionStart={() => setIsComposing(true)}
onCompositionEnd={() => setIsComposing(false)}
placeholder="質問を入力してください..."
rows={2}
disabled={loading || !user}
className="question-input"
/>
<button
type="submit"
disabled={loading || !question.trim() || !user}
className="send-btn"
>
{loading ? '⏳' : '📤'}
</button>
</div>
{!user && (
<div className="auth-notice">
ログインしてください
</div>
)}
</form>
<style jsx>{`
.ask-ai-container {
width: 100%;
max-width: 600px;
height: 500px;
display: flex;
flex-direction: column;
border: 1px solid #ddd;
border-radius: 8px;
background: white;
overflow: hidden;
}
.ask-ai-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px;
border-bottom: 1px solid #eee;
background: #f8f9fa;
}
.ask-ai-header h3 {
margin: 0;
color: #333;
}
.header-actions {
display: flex;
gap: 8px;
}
.clear-btn, .close-btn {
background: none;
border: none;
cursor: pointer;
padding: 5px;
border-radius: 4px;
font-size: 14px;
}
.clear-btn:hover, .close-btn:hover {
background: #e9ecef;
}
.chat-container {
flex: 1;
overflow-y: auto;
padding: 15px;
display: flex;
flex-direction: column;
gap: 15px;
}
.chat-message {
display: flex;
flex-direction: column;
gap: 10px;
}
.user-message, .ai-message, .welcome-message {
display: flex;
flex-direction: column;
}
.user-message {
align-self: flex-end;
max-width: 80%;
}
.ai-message, .welcome-message {
align-self: flex-start;
max-width: 90%;
}
.message-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 5px;
}
.avatar {
width: 24px;
height: 24px;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
}
.profile-avatar {
width: 100%;
height: 100%;
object-fit: cover;
}
.user-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.display-name {
font-weight: bold;
font-size: 12px;
color: #333;
}
.handle {
font-size: 11px;
color: #666;
}
.timestamp {
font-size: 10px;
color: #999;
}
.message-content {
background: #f1f3f4;
padding: 10px 12px;
border-radius: 12px;
font-size: 14px;
line-height: 1.4;
white-space: pre-wrap;
}
.user-message .message-content {
background: #007bff;
color: white;
}
.ai-message .message-content {
background: #e9ecef;
color: #333;
}
.ai-loading {
align-self: flex-start;
max-width: 90%;
}
.error-message {
background: #f8d7da;
color: #721c24;
padding: 10px;
border-radius: 8px;
border: 1px solid #f5c6cb;
}
.question-form {
padding: 15px;
border-top: 1px solid #eee;
background: #f8f9fa;
}
.input-container {
display: flex;
gap: 8px;
align-items: end;
}
.question-input {
flex: 1;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 14px;
resize: none;
font-family: inherit;
}
.question-input:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
.question-input:disabled {
background: #e9ecef;
cursor: not-allowed;
}
.send-btn {
background: #007bff;
color: white;
border: none;
padding: 8px 12px;
border-radius: 8px;
cursor: pointer;
font-size: 16px;
transition: background 0.2s;
}
.send-btn:hover:not(:disabled) {
background: #0056b3;
}
.send-btn:disabled {
background: #6c757d;
cursor: not-allowed;
}
.auth-notice {
text-align: center;
font-size: 12px;
color: #666;
margin-top: 8px;
}
`}</style>
</div>
)
}

@@ -0,0 +1,77 @@
import React, { useState } from 'react'
export default function AuthButton({ user, onLogin, onLogout, loading }) {
const [handleInput, setHandleInput] = useState('')
const [isLoading, setIsLoading] = useState(false)
const handleSubmit = async (e) => {
e.preventDefault()
if (!handleInput.trim() || isLoading) return
setIsLoading(true)
try {
await onLogin(handleInput.trim())
} catch (error) {
console.error('Login failed:', error)
alert('ログインに失敗しました: ' + error.message)
} finally {
setIsLoading(false)
}
}
if (loading) {
return <div>認証状態を確認中...</div>
}
if (user) {
return (
<div className="user-section" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{user.avatar && (
<img
src={user.avatar}
alt="Profile"
className="user-avatar"
style={{ width: '24px', height: '24px' }}
/>
)}
<div>
<div className="user-display-name" style={{ fontSize: '14px', fontWeight: '700' }}>
{user.displayName}
</div>
<div className="user-handle" style={{ fontSize: '12px' }}>
@{user.handle}
</div>
</div>
<button onClick={onLogout} className="btn btn-danger btn-sm">
ログアウト
</button>
</div>
)
}
return (
<div className="auth-section search-bar-layout">
<input
type="text"
value={handleInput}
onChange={(e) => setHandleInput(e.target.value)}
placeholder="user.bsky.social"
disabled={isLoading}
className="handle-input"
onKeyPress={(e) => {
if (e.key === 'Enter') {
handleSubmit(e)
}
}}
/>
<button
type="button"
onClick={handleSubmit}
disabled={isLoading || !handleInput.trim()}
className={`auth-button ${isLoading ? 'loading' : ''}`}
>
<i className={isLoading ? "fas fa-spinner" : "fab fa-bluesky"}></i>
</button>
</div>
)
}

@@ -0,0 +1,234 @@
import React, { useState, useEffect } from 'react'
import { getAvatar } from '../utils/avatar.js'
/**
* Avatar component with intelligent fallback
*
* @param {Object} props
* @param {Object} props.record - Record object containing avatar data
* @param {string} props.handle - User handle
* @param {string} props.did - User DID
* @param {string} props.alt - Alt text for image
* @param {string} props.className - CSS class name
* @param {number} props.size - Avatar size in pixels
* @param {boolean} props.showFallback - Show fallback UI if no avatar
* @param {Function} props.onLoad - Callback when avatar loads
* @param {Function} props.onError - Callback when avatar fails to load
*/
export default function Avatar({
record,
handle,
did,
alt = 'avatar',
className = 'avatar',
size = 40,
showFallback = true,
onLoad,
onError
}) {
const [avatarUrl, setAvatarUrl] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [imageError, setImageError] = useState(false)
useEffect(() => {
let cancelled = false
async function loadAvatar() {
try {
setLoading(true)
setError(null)
setImageError(false)
const url = await getAvatar({ record, handle, did })
if (!cancelled) {
setAvatarUrl(url)
setLoading(false)
}
} catch (err) {
if (!cancelled) {
setError(err.message)
setLoading(false)
if (onError) onError(err)
}
}
}
loadAvatar()
return () => {
cancelled = true
}
}, [record, handle, did])
const handleImageError = async () => {
setImageError(true)
if (onError) onError(new Error('Image failed to load'))
// Try to fetch fresh avatar if the current one failed
if (!loading && avatarUrl) {
try {
const freshUrl = await getAvatar({ handle, did, forceFresh: true })
if (freshUrl && freshUrl !== avatarUrl) {
setAvatarUrl(freshUrl)
setImageError(false)
}
} catch {
// Ignore errors in retry
}
}
}
const handleImageLoad = () => {
setImageError(false)
if (onLoad) onLoad()
}
// Determine what to render
if (loading) {
return (
<div
className={`${className} avatar-loading`}
style={{ width: size, height: size }}
aria-label="Loading avatar..."
/>
)
}
if (error || !avatarUrl || imageError) {
if (!showFallback) return null
// Fallback avatar
const initial = (handle || 'U')[0].toUpperCase()
return (
<div
className={`${className} avatar-fallback`}
style={{
width: size,
height: size,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#e1e1e1',
borderRadius: '50%',
fontSize: size * 0.4
}}
aria-label={alt}
>
{initial}
</div>
)
}
return (
<img
src={avatarUrl}
alt={alt}
className={className}
style={{ width: size, height: size }}
onError={handleImageError}
onLoad={handleImageLoad}
loading="lazy"
/>
)
}
/**
* Avatar with hover card showing user info
*/
export function AvatarWithCard({
record,
handle,
did,
displayName,
apiConfig,
...avatarProps
}) {
const [showCard, setShowCard] = useState(false)
return (
<div
className="avatar-container"
onMouseEnter={() => setShowCard(true)}
onMouseLeave={() => setShowCard(false)}
>
<Avatar
record={record}
handle={handle}
did={did}
{...avatarProps}
/>
{showCard && (
<div className="avatar-card">
<Avatar
record={record}
handle={handle}
did={did}
size={80}
className="avatar-card-image"
/>
<div className="avatar-card-info">
<div className="avatar-card-name">{displayName || handle}</div>
<a
href={`${apiConfig?.web || 'https://bsky.app'}/profile/${did || handle}`}
target="_blank"
rel="noopener noreferrer"
className="avatar-card-handle"
>
@{handle}
</a>
</div>
</div>
)}
</div>
)
}
/**
* Avatar list component for displaying multiple avatars
*/
export function AvatarList({ users, maxDisplay = 5, size = 30 }) {
const displayUsers = users.slice(0, maxDisplay)
const remainingCount = Math.max(0, users.length - maxDisplay)
return (
<div className="avatar-list">
{displayUsers.map((user, index) => (
<div
key={user.handle || index}
className="avatar-list-item"
style={{ marginLeft: index > 0 ? -10 : 0, zIndex: displayUsers.length - index }}
>
<Avatar
handle={user.handle}
did={user.did}
record={user.record}
size={size}
showFallback={true}
/>
</div>
))}
{remainingCount > 0 && (
<div
className="avatar-list-more"
style={{
width: size,
height: size,
marginLeft: -10,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#666',
color: '#fff',
borderRadius: '50%',
fontSize: size * 0.4
}}
>
+{remainingCount}
</div>
)}
</div>
)
}

@@ -0,0 +1,103 @@
import React, { useState, useEffect } from 'react'
import { getValidAvatar } from '../utils/avatarFetcher.js'
import { logger } from '../utils/logger.js'
export default function AvatarImage({ record, size = 40, className = "avatar" }) {
const [avatarUrl, setAvatarUrl] = useState(record?.value?.author?.avatar)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(false)
const author = record?.value?.author
const handle = author?.handle
const displayName = author?.displayName || handle
useEffect(() => {
// record内のavatarが無い、またはエラーの場合に新しく取得
if (!avatarUrl || error) {
fetchValidAvatar()
}
}, [record, error])
const fetchValidAvatar = async () => {
if (!record || loading) return
setLoading(true)
try {
const validAvatar = await getValidAvatar(record)
setAvatarUrl(validAvatar)
setError(false)
} catch (err) {
logger.error('Failed to fetch valid avatar:', err)
setError(true)
} finally {
setLoading(false)
}
}
const handleImageError = () => {
setError(true)
// エラー時に再取得を試行
fetchValidAvatar()
}
const handleImageLoad = () => {
setError(false)
}
// ローディング中のスケルトン
if (loading) {
return (
<div
className={`${className} avatar-loading`}
style={{
width: size,
height: size,
backgroundColor: '#f0f0f0',
borderRadius: '50%',
animation: 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite'
}}
/>
)
}
// avatar URLがある場合
if (avatarUrl && !error) {
return (
<img
src={avatarUrl}
alt={`${displayName} avatar`}
className={className}
style={{
width: size,
height: size,
borderRadius: '50%',
objectFit: 'cover'
}}
onError={handleImageError}
onLoad={handleImageLoad}
/>
)
}
// フォールバック: 初期文字のアバター
const initial = displayName ? displayName.charAt(0).toUpperCase() : '?'
return (
<div
className={`${className} avatar-fallback`}
style={{
width: size,
height: size,
borderRadius: '50%',
backgroundColor: '#ddd',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: size * 0.4,
fontWeight: 'bold',
color: '#666'
}}
>
{initial}
</div>
)
}

@@ -0,0 +1,203 @@
import React, { useState, useEffect } from 'react'
import Avatar, { AvatarWithCard, AvatarList } from './Avatar.jsx'
import { getAvatar, batchFetchAvatars, prefetchAvatar } from '../utils/avatar.js'
/**
* Test component to demonstrate avatar functionality
*/
export default function AvatarTest() {
const [testResults, setTestResults] = useState({})
const [loading, setLoading] = useState(false)
// Test data
const testUsers = [
{ handle: 'syui.ai', did: 'did:plc:uqzpqmrjnptsxezjx4xuh2mn' },
{ handle: 'ai.syui.ai', did: 'did:plc:4hqjfn7m6n5hno3doamuhgef' },
{ handle: 'yui.syui.ai', did: 'did:plc:6qyecktefllvenje24fcxnie' }
]
const sampleRecord = {
value: {
author: {
handle: 'syui.ai',
did: 'did:plc:uqzpqmrjnptsxezjx4xuh2mn',
displayName: 'syui',
avatar: 'https://cdn.bsky.app/img/avatar/plain/did:plc:uqzpqmrjnptsxezjx4xuh2mn/bafkreid6kcc5pnn4b3ar7mj6vi3eiawhxgkcrw3edgbqeacyrlnlcoetea@jpeg'
},
text: 'Test message',
createdAt: new Date().toISOString()
}
}
// Test functions
const testGetAvatar = async () => {
setLoading(true)
try {
const results = {}
// Test with record
results.fromRecord = await getAvatar({ record: sampleRecord })
// Test with handle only
results.fromHandle = await getAvatar({ handle: 'syui.ai' })
// Test with broken record (force fresh fetch)
const brokenRecord = {
...sampleRecord,
value: {
...sampleRecord.value,
author: {
...sampleRecord.value.author,
avatar: 'https://broken-url.com/avatar.jpg'
}
}
}
results.brokenRecord = await getAvatar({ record: brokenRecord })
// Test non-existent user
try {
results.nonExistent = await getAvatar({ handle: 'nonexistent.user' })
} catch (error) {
results.nonExistent = `Error: ${error.message}`
}
setTestResults(results)
} catch (error) {
console.error('Test failed:', error)
} finally {
setLoading(false)
}
}
const testBatchFetch = async () => {
setLoading(true)
try {
const avatarMap = await batchFetchAvatars(testUsers)
setTestResults(prev => ({
...prev,
batchResults: Object.fromEntries(avatarMap)
}))
} catch (error) {
console.error('Batch test failed:', error)
} finally {
setLoading(false)
}
}
const testPrefetch = async () => {
setLoading(true)
try {
await prefetchAvatar('syui.ai')
const cachedAvatar = await getAvatar({ handle: 'syui.ai' })
setTestResults(prev => ({
...prev,
prefetchResult: cachedAvatar
}))
} catch (error) {
console.error('Prefetch test failed:', error)
} finally {
setLoading(false)
}
}
return (
<div className="avatar-test-container">
<div className="card">
<div className="card-header">
<h2>Avatar System Test</h2>
</div>
<div className="card-content">
{/* Basic Avatar Examples */}
<section className="test-section">
<h3>Basic Avatar Examples</h3>
<div className="avatar-examples">
<div className="avatar-example">
<h4>From Record</h4>
<Avatar record={sampleRecord} size={60} />
</div>
<div className="avatar-example">
<h4>From Handle</h4>
<Avatar handle="syui.ai" size={60} />
</div>
<div className="avatar-example">
<h4>With Fallback</h4>
<Avatar handle="nonexistent.user" size={60} />
</div>
<div className="avatar-example">
<h4>Loading State</h4>
<div className="avatar-loading" style={{ width: 60, height: 60 }} />
</div>
</div>
</section>
{/* Avatar with Card */}
<section className="test-section">
<h3>Avatar with Hover Card</h3>
<div className="avatar-examples">
<AvatarWithCard
record={sampleRecord}
displayName="syui"
apiConfig={{ web: 'https://bsky.app' }}
size={60}
/>
<p>Hover over the avatar to see the card</p>
</div>
</section>
{/* Avatar List */}
<section className="test-section">
<h3>Avatar List</h3>
<AvatarList users={testUsers} maxDisplay={3} size={40} />
</section>
{/* Test Controls */}
<section className="test-section">
<h3>Test Functions</h3>
<div className="test-controls">
<button
onClick={testGetAvatar}
disabled={loading}
className="btn btn-primary"
>
Test getAvatar()
</button>
<button
onClick={testBatchFetch}
disabled={loading}
className="btn btn-primary"
>
Test Batch Fetch
</button>
<button
onClick={testPrefetch}
disabled={loading}
className="btn btn-primary"
>
Test Prefetch
</button>
</div>
</section>
{/* Test Results */}
{Object.keys(testResults).length > 0 && (
<section className="test-section">
<h3>Test Results</h3>
<div className="json-display">
<pre className="json-content">
{JSON.stringify(testResults, null, 2)}
</pre>
</div>
</section>
)}
</div>
</div>
</div>
)
}

@@ -0,0 +1,246 @@
import React, { useState } from 'react'
import AvatarImage from './AvatarImage.jsx'
import { getValidAvatar, clearAvatarCache, getAvatarCacheStats } from '../utils/avatarFetcher.js'
export default function AvatarTestPanel() {
const [testHandle, setTestHandle] = useState('ai.syui.ai')
const [testResult, setTestResult] = useState(null)
const [loading, setLoading] = useState(false)
const [cacheStats, setCacheStats] = useState(null)
// ダミーレコードを作成(実際の投稿したレコード形式)
const createTestRecord = (handle, brokenAvatar = false) => ({
value: {
author: {
did: null, // DIDはnullにして、handleから取得させる
handle: handle,
displayName: "Test User",
avatar: brokenAvatar ? "https://broken.example.com/avatar.jpg" : null
},
text: "テストコメント",
createdAt: new Date().toISOString()
}
})
const testAvatarFetch = async (useBrokenAvatar = false) => {
setLoading(true)
setTestResult(null)
try {
const testRecord = createTestRecord(testHandle, useBrokenAvatar)
const avatarUrl = await getValidAvatar(testRecord)
setTestResult({
success: true,
avatarUrl,
handle: testHandle,
brokenTest: useBrokenAvatar,
timestamp: new Date().toISOString()
})
} catch (error) {
setTestResult({
success: false,
error: error.message,
handle: testHandle,
brokenTest: useBrokenAvatar
})
} finally {
setLoading(false)
}
}
const handleClearCache = () => {
clearAvatarCache()
setCacheStats(null)
alert('Avatar cache cleared!')
}
const handleShowCacheStats = () => {
const stats = getAvatarCacheStats()
setCacheStats(stats)
}
return (
<div className="test-ui">
<h2>🖼 Avatar Test Panel</h2>
<p className="description">
Avatar取得システムのテスト投稿済みのdummy recordを使用してavatar取得処理を確認できます
</p>
<div className="form-group">
<label htmlFor="test-handle">Test Handle:</label>
<input
id="test-handle"
type="text"
value={testHandle}
onChange={(e) => setTestHandle(e.target.value)}
placeholder="ai.syui.ai"
disabled={loading}
/>
</div>
<div className="form-actions">
<button
onClick={() => testAvatarFetch(false)}
disabled={loading || !testHandle.trim()}
className="btn btn-primary"
>
{loading ? '⏳ Testing...' : '🔄 Test Avatar Fetch'}
</button>
<button
onClick={() => testAvatarFetch(true)}
disabled={loading || !testHandle.trim()}
className="btn btn-outline"
>
{loading ? '⏳ Testing...' : '💥 Test Broken Avatar'}
</button>
<button
onClick={handleClearCache}
disabled={loading}
className="btn btn-danger btn-sm"
>
🗑 Clear Cache
</button>
<button
onClick={handleShowCacheStats}
disabled={loading}
className="btn btn-outline btn-sm"
>
📊 Cache Stats
</button>
</div>
{testResult && (
<div className="test-result">
<h3>Test Result:</h3>
{testResult.success ? (
<div className="success-message">
Avatar fetched successfully!
<div className="result-details">
<p><strong>Handle:</strong> {testResult.handle}</p>
<p><strong>Broken Test:</strong> {testResult.brokenTest ? 'Yes' : 'No'}</p>
<p><strong>Avatar URL:</strong> {testResult.avatarUrl || 'None'}</p>
<p><strong>Timestamp:</strong> {testResult.timestamp}</p>
{testResult.avatarUrl && (
<div className="avatar-preview">
<p><strong>Preview:</strong></p>
<img
src={testResult.avatarUrl}
alt="Avatar preview"
style={{
width: 60,
height: 60,
borderRadius: '50%',
objectFit: 'cover',
border: '2px solid #ddd'
}}
/>
</div>
)}
</div>
</div>
) : (
<div className="error-message">
Test failed: {testResult.error}
</div>
)}
</div>
)}
{cacheStats && (
<div className="cache-stats">
<h3>Cache Statistics:</h3>
<p><strong>Entries:</strong> {cacheStats.size}</p>
{cacheStats.entries.length > 0 && (
<div className="cache-entries">
<h4>Cached Avatars:</h4>
{cacheStats.entries.map((entry, i) => (
<div key={i} className="cache-entry">
<p><strong>Key:</strong> {entry.key}</p>
<p><strong>Age:</strong> {Math.floor(entry.age / 1000)}s</p>
<p><strong>Profile:</strong> {entry.profile?.displayName} (@{entry.profile?.handle})</p>
{entry.avatar && (
<img
src={entry.avatar}
alt="Cached avatar"
style={{ width: 30, height: 30, borderRadius: '50%' }}
/>
)}
</div>
))}
</div>
)}
</div>
)}
<div className="live-demo">
<h3>Live Avatar Component Demo:</h3>
<p>実際のAvatarImageコンポーネントの動作確認:</p>
<div style={{ display: 'flex', gap: '16px', alignItems: 'center', marginTop: '12px' }}>
<AvatarImage record={createTestRecord(testHandle, false)} size={40} />
<span>Normal avatar test</span>
</div>
<div style={{ display: 'flex', gap: '16px', alignItems: 'center', marginTop: '12px' }}>
<AvatarImage record={createTestRecord(testHandle, true)} size={40} />
<span>Broken avatar test (should fetch fresh)</span>
</div>
</div>
<style jsx>{`
.test-result {
margin-top: 20px;
padding: 16px;
border: 1px solid #ddd;
border-radius: 8px;
background: #f9f9f9;
}
.result-details {
margin-top: 12px;
font-size: 14px;
}
.result-details p {
margin: 4px 0;
}
.avatar-preview {
margin-top: 12px;
padding: 12px;
border: 1px solid #eee;
border-radius: 4px;
background: white;
}
.cache-stats {
margin-top: 20px;
padding: 16px;
border: 1px solid #ddd;
border-radius: 8px;
background: #f0f8ff;
}
.cache-entries {
margin-top: 12px;
}
.cache-entry {
padding: 8px;
margin: 8px 0;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
font-size: 12px;
}
.cache-entry p {
margin: 2px 0;
}
.live-demo {
margin-top: 20px;
padding: 16px;
border: 1px solid #ddd;
border-radius: 8px;
background: #f8f9fa;
}
`}</style>
</div>
)
}

@@ -1,120 +0,0 @@
import React from 'react';
import { motion } from 'framer-motion';
import { Card as CardType, CardRarity } from '../types/card';
import '../styles/Card.css';
interface CardProps {
card: CardType;
isRevealing?: boolean;
detailed?: boolean;
}
const CARD_INFO: Record<number, { name: string; color: string }> = {
0: { name: "アイ", color: "#fff700" },
1: { name: "夢幻", color: "#b19cd9" },
2: { name: "光彩", color: "#ffd700" },
3: { name: "中性子", color: "#cacfd2" },
4: { name: "太陽", color: "#ff6b35" },
5: { name: "夜空", color: "#1a1a2e" },
6: { name: "雪", color: "#e3f2fd" },
7: { name: "雷", color: "#ffd93d" },
8: { name: "超究", color: "#6c5ce7" },
9: { name: "剣", color: "#a8e6cf" },
10: { name: "破壊", color: "#ff4757" },
11: { name: "地球", color: "#4834d4" },
12: { name: "天の川", color: "#9c88ff" },
13: { name: "創造", color: "#00d2d3" },
14: { name: "超新星", color: "#ff9ff3" },
15: { name: "世界", color: "#54a0ff" },
};
export const Card: React.FC<CardProps> = ({ card, isRevealing = false, detailed = false }) => {
const cardInfo = CARD_INFO[card.id] || { name: "Unknown", color: "#666" };
const imageUrl = `https://git.syui.ai/ai/card/raw/branch/main/img/${card.id}.webp`;
const getRarityClass = () => {
switch (card.status) {
case CardRarity.UNIQUE:
return 'card-unique';
case CardRarity.KIRA:
return 'card-kira';
case CardRarity.SUPER_RARE:
return 'card-super-rare';
case CardRarity.RARE:
return 'card-rare';
default:
return 'card-normal';
}
};
if (!detailed) {
// Simple view - only image and frame
return (
<motion.div
className={`card card-simple ${getRarityClass()}`}
initial={isRevealing ? { rotateY: 180 } : {}}
animate={isRevealing ? { rotateY: 0 } : {}}
transition={{ duration: 0.8, type: "spring" }}
>
<div className="card-frame">
<img
src={imageUrl}
alt={cardInfo.name}
className="card-image-simple"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
</div>
</motion.div>
);
}
// Detailed view - all information
return (
<motion.div
className={`card ${getRarityClass()}`}
initial={isRevealing ? { rotateY: 180 } : {}}
animate={isRevealing ? { rotateY: 0 } : {}}
transition={{ duration: 0.8, type: "spring" }}
style={{
'--card-color': cardInfo.color,
} as React.CSSProperties}
>
<div className="card-inner">
<div className="card-header">
<span className="card-id">#{card.id}</span>
<span className="card-cp">CP: {card.cp}</span>
</div>
<div className="card-image-container">
<img
src={imageUrl}
alt={cardInfo.name}
className="card-image"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
</div>
<div className="card-content">
<h3 className="card-name">{cardInfo.name}</h3>
{card.is_unique && (
<div className="unique-badge">UNIQUE</div>
)}
</div>
{card.skill && (
<div className="card-skill">
<p>{card.skill}</p>
</div>
)}
<div className="card-footer">
<span className="card-rarity">{card.status.toUpperCase()}</span>
</div>
</div>
</motion.div>
);
};

@@ -1,171 +0,0 @@
import React, { useState, useEffect } from 'react';
import { atprotoOAuthService } from '../services/atproto-oauth';
import { Card } from './Card';
import '../styles/CardBox.css';
interface CardBoxProps {
userDid: string;
}
export const CardBox: React.FC<CardBoxProps> = ({ userDid }) => {
const [boxData, setBoxData] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showJson, setShowJson] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
useEffect(() => {
loadBoxData();
}, [userDid]);
const loadBoxData = async () => {
setLoading(true);
setError(null);
try {
const data = await atprotoOAuthService.getCardsFromBox();
setBoxData(data);
} catch (err) {
// Failed to load card box
setError(err instanceof Error ? err.message : 'カードボックスの読み込みに失敗しました');
} finally {
setLoading(false);
}
};
const handleSaveToBox = async () => {
// 現在のカードデータを取得してボックスに保存
// この部分は親コンポーネントから渡すか、APIから取得する必要があります
alert('カードボックスへの保存機能は親コンポーネントから実行してください');
};
const handleDeleteBox = async () => {
if (!window.confirm('カードボックスを削除してもよろしいですか?\nこの操作は取り消せません。')) {
return;
}
setIsDeleting(true);
setError(null);
try {
await atprotoOAuthService.deleteCardBox();
setBoxData({ records: [] });
alert('カードボックスを削除しました');
} catch (err) {
// Failed to delete card box
setError(err instanceof Error ? err.message : 'カードボックスの削除に失敗しました');
} finally {
setIsDeleting(false);
}
};
if (loading) {
return (
<div className="card-box-container">
<div className="loading">...</div>
</div>
);
}
if (error) {
return (
<div className="card-box-container">
<div className="error">: {error}</div>
<button onClick={loadBoxData} className="retry-button">
</button>
</div>
);
}
const records = boxData?.records || [];
const selfRecord = records.find((record: any) => record.uri.includes('/self'));
const cards = selfRecord?.value?.cards || [];
return (
<div className="card-box-container">
<div className="card-box-header">
<h3>📦 atproto </h3>
<div className="box-actions">
<button
onClick={() => setShowJson(!showJson)}
className="json-button"
>
{showJson ? 'JSON非表示' : 'JSON表示'}
</button>
<button onClick={loadBoxData} className="refresh-button">
🔄
</button>
{cards.length > 0 && (
<button
onClick={handleDeleteBox}
className="delete-button"
disabled={isDeleting}
>
{isDeleting ? '削除中...' : '🗑️ 削除'}
</button>
)}
</div>
</div>
<div className="uri-display">
<p>
<strong>📍 URI:</strong>
<code>at://did:plc:uqzpqmrjnptsxezjx4xuh2mn/ai.card.box/self</code>
</p>
</div>
{showJson && (
<div className="json-display">
<h4>Raw JSON :</h4>
<pre className="json-content">
{JSON.stringify(boxData, null, 2)}
</pre>
</div>
)}
<div className="box-stats">
<p>
<strong>:</strong> {cards.length}
{selfRecord?.value?.updated_at && (
<>
<br />
<strong>:</strong> {new Date(selfRecord.value.updated_at).toLocaleString()}
</>
)}
</p>
</div>
{cards.length > 0 ? (
<>
<div className="card-grid">
{cards.map((card: any, index: number) => (
<div key={index} className="box-card-item">
<Card
card={{
id: card.id,
cp: card.cp,
status: card.status,
skill: card.skill,
owner_did: card.owner_did,
obtained_at: card.obtained_at,
is_unique: card.is_unique,
unique_id: card.unique_id
}}
/>
<div className="card-info">
<small>ID: {card.id} | CP: {card.cp}</small>
</div>
</div>
))}
</div>
</>
) : (
<div className="empty-box">
<p></p>
<p></p>
</div>
)}
</div>
);
};

@@ -1,113 +0,0 @@
import React, { useState, useEffect } from 'react';
import { Card } from './Card';
import { cardApi } from '../services/api';
import { Card as CardType } from '../types/card';
import '../styles/CardList.css';
interface CardMasterData {
id: number;
name: string;
ja_name: string;
description: string;
base_cp_min: number;
base_cp_max: number;
}
export const CardList: React.FC = () => {
const [loading, setLoading] = useState(true);
const [masterData, setMasterData] = useState<CardMasterData[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadMasterData();
}, []);
const loadMasterData = async () => {
try {
setLoading(true);
const response = await fetch('http://localhost:8000/api/v1/cards/master');
if (!response.ok) {
throw new Error('Failed to fetch card master data');
}
const data = await response.json();
setMasterData(data);
} catch (err) {
// Failed to load card master data
setError(err instanceof Error ? err.message : 'Failed to load card data');
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="card-list-container">
<div className="loading">Loading card data...</div>
</div>
);
}
if (error) {
return (
<div className="card-list-container">
<div className="error">Error: {error}</div>
<button onClick={loadMasterData}>Retry</button>
</div>
);
}
// Create cards for all rarity patterns
const rarityPatterns = ['normal', 'unique'] as const;
const displayCards: Array<{card: CardType, data: CardMasterData, patternName: string}> = [];
masterData.forEach(data => {
rarityPatterns.forEach(pattern => {
const card: CardType = {
id: data.id,
cp: Math.floor((data.base_cp_min + data.base_cp_max) / 2),
status: pattern,
skill: null,
owner_did: 'sample',
obtained_at: new Date().toISOString(),
is_unique: pattern === 'unique',
unique_id: pattern === 'unique' ? 'sample-unique-id' : null
};
displayCards.push({
card,
data,
patternName: `${data.id}-${pattern}`
});
});
});
return (
<div className="card-list-container">
<header className="card-list-header">
<h1>ai.card </h1>
<p></p>
<p className="source-info">データソース: https://git.syui.ai/ai/ai/raw/branch/main/ai.json</p>
</header>
<div className="card-list-simple-grid">
{displayCards.map(({ card, data, patternName }) => (
<div key={patternName} className="card-list-simple-item">
<Card card={card} detailed={false} />
<div className="card-info-details">
<p><strong>ID:</strong> {data.id}</p>
<p><strong>Name:</strong> {data.name}</p>
<p><strong>:</strong> {data.ja_name}</p>
<p><strong>:</strong> {card.status}</p>
<p><strong>CP:</strong> {card.cp}</p>
<p><strong>CP範囲:</strong> {data.base_cp_min}-{data.base_cp_max}</p>
{data.description && (
<p className="card-description">{data.description}</p>
)}
</div>
</div>
))}
</div>
</div>
);
};

@@ -0,0 +1,231 @@
import React, { useState } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeHighlight from 'rehype-highlight'
import 'highlight.js/styles/github-dark.css'
// Helper function to get correct web URL based on avatar URL
function getCorrectWebUrl(avatarUrl) {
if (!avatarUrl) return 'https://bsky.app'
// If avatar is from bsky.app (main Bluesky), use bsky.app
if (avatarUrl.includes('cdn.bsky.app') || avatarUrl.includes('bsky.app')) {
return 'https://bsky.app'
}
// If avatar is from syu.is, use web.syu.is
if (avatarUrl.includes('bsky.syu.is') || avatarUrl.includes('syu.is')) {
return 'https://syu.is'
}
// Default to bsky.app
return 'https://bsky.app'
}
export default function ChatRecordList({ chatPairs, chatHasMore, onLoadMoreChat, apiConfig, user = null, agent = null, onRecordDeleted = null }) {
const [expandedRecords, setExpandedRecords] = useState(new Set())
const toggleJsonView = (key) => {
const newExpanded = new Set(expandedRecords)
if (newExpanded.has(key)) {
newExpanded.delete(key)
} else {
newExpanded.add(key)
}
setExpandedRecords(newExpanded)
}
if (!chatPairs || chatPairs.length === 0) {
return (
<section>
<p>チャット履歴がありません</p>
</section>
)
}
const handleDelete = async (chatPair) => {
if (!user || !agent || !chatPair.question?.uri) return
const confirmed = window.confirm('この会話を削除しますか?')
if (!confirmed) return
try {
// Delete question record
if (chatPair.question?.uri) {
const questionUriParts = chatPair.question.uri.split('/')
await agent.api.com.atproto.repo.deleteRecord({
repo: questionUriParts[2],
collection: questionUriParts[3],
rkey: questionUriParts[4]
})
}
// Delete answer record if exists
if (chatPair.answer?.uri) {
const answerUriParts = chatPair.answer.uri.split('/')
await agent.api.com.atproto.repo.deleteRecord({
repo: answerUriParts[2],
collection: answerUriParts[3],
rkey: answerUriParts[4]
})
}
if (onRecordDeleted) {
onRecordDeleted()
}
} catch (error) {
alert(`削除に失敗しました: ${error.message}`)
}
}
const canDelete = (chatPair) => {
return user && agent && chatPair.question?.uri && chatPair.question.value.author?.did === user.did
}
return (
<section>
{chatPairs.map((chatPair, i) => (
<div key={chatPair.rkey} className="chat-conversation">
{/* Question */}
{chatPair.question && (
<div className="chat-message user-message comment-style">
<div className="message-header">
{chatPair.question.value.author?.avatar ? (
<img
src={chatPair.question.value.author.avatar}
alt={`${chatPair.question.value.author.displayName || chatPair.question.value.author.handle} avatar`}
className="avatar"
/>
) : (
<div className="avatar">
{(chatPair.question.value.author?.displayName || chatPair.question.value.author?.handle || '?').charAt(0).toUpperCase()}
</div>
)}
<div className="user-info">
<div className="display-name">
{chatPair.question.value.author?.displayName || chatPair.question.value.author?.handle}
{chatPair.question.value.author?.handle === 'syui' && <span className="admin-badge"> Admin</span>}
</div>
<div className="handle">
<a
href={`${getCorrectWebUrl(chatPair.question.value.author?.avatar)}/profile/${chatPair.question.value.author?.did}`}
target="_blank"
rel="noopener noreferrer"
className="handle-link"
>
@{chatPair.question.value.author?.handle}
</a>
</div>
</div>
<div className="record-actions">
<button
onClick={() => toggleJsonView(`${chatPair.rkey}-question`)}
className={`btn btn-sm ${expandedRecords.has(`${chatPair.rkey}-question`) ? 'btn-outline' : 'btn-primary'}`}
title="Show/Hide JSON"
>
{expandedRecords.has(`${chatPair.rkey}-question`) ? 'hide' : 'json'}
</button>
{canDelete(chatPair) && (
<button
onClick={() => handleDelete(chatPair)}
className="btn btn-danger btn-sm"
title="Delete Conversation"
>
delete
</button>
)}
</div>
</div>
{expandedRecords.has(`${chatPair.rkey}-question`) && (
<div className="json-display">
<pre className="json-content">
{JSON.stringify(chatPair.question, null, 2)}
</pre>
</div>
)}
<div className="message-content">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight]}
>
{chatPair.question.value.text}
</ReactMarkdown>
</div>
</div>
)}
{/* Answer */}
{chatPair.answer && (
<div className="chat-message ai-message comment-style">
<div className="message-header">
{chatPair.answer.value.author?.avatar ? (
<img
src={chatPair.answer.value.author.avatar}
alt={`${chatPair.answer.value.author.displayName || chatPair.answer.value.author.handle} avatar`}
className="avatar"
/>
) : (
<div className="avatar">
{(chatPair.answer.value.author?.displayName || chatPair.answer.value.author?.handle || 'AI').charAt(0).toUpperCase()}
</div>
)}
<div className="user-info">
<div className="display-name">
{chatPair.answer.value.author?.displayName || chatPair.answer.value.author?.handle}
</div>
<div className="handle">
<a
href={`${getCorrectWebUrl(chatPair.answer.value.author?.avatar)}/profile/${chatPair.answer.value.author?.did}`}
target="_blank"
rel="noopener noreferrer"
className="handle-link"
>
@{chatPair.answer.value.author?.handle}
</a>
</div>
</div>
<div className="record-actions">
<button
onClick={() => toggleJsonView(`${chatPair.rkey}-answer`)}
className={`btn btn-sm ${expandedRecords.has(`${chatPair.rkey}-answer`) ? 'btn-outline' : 'btn-primary'}`}
title="Show/Hide JSON"
>
{expandedRecords.has(`${chatPair.rkey}-answer`) ? 'hide' : 'json'}
</button>
</div>
</div>
{expandedRecords.has(`${chatPair.rkey}-answer`) && (
<div className="json-display">
<pre className="json-content">
{JSON.stringify(chatPair.answer, null, 2)}
</pre>
</div>
)}
<div className="message-content">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight]}
>
{chatPair.answer.value.text}
</ReactMarkdown>
</div>
</div>
)}
</div>
))}
{/* Load More Button */}
{chatHasMore && onLoadMoreChat && (
<div className="bluesky-footer">
<i
className="fab fa-bluesky"
onClick={onLoadMoreChat}
style={{cursor: 'pointer'}}
title="続きを読み込む"
></i>
</div>
)}
</section>
)
}

@@ -1,133 +0,0 @@
import React, { useState, useEffect } from 'react';
import { aiCardApi } from '../services/api';
import '../styles/CollectionAnalysis.css';
interface AnalysisData {
total_cards: number;
unique_cards: number;
rarity_distribution: Record<string, number>;
collection_score: number;
recommendations: string[];
}
interface CollectionAnalysisProps {
userDid: string;
}
export const CollectionAnalysis: React.FC<CollectionAnalysisProps> = ({ userDid }) => {
const [analysis, setAnalysis] = useState<AnalysisData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadAnalysis = async () => {
if (!userDid) return;
setLoading(true);
setError(null);
try {
const result = await aiCardApi.analyzeCollection(userDid);
setAnalysis(result);
} catch (err) {
// Collection analysis failed
setError('AI分析機能を利用するにはai.gptサーバーが必要です。基本機能はai.cardサーバーのみで利用できます。');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadAnalysis();
}, [userDid]);
if (loading) {
return (
<div className="collection-analysis">
<div className="analysis-loading">
<div className="loading-spinner"></div>
<p>AI分析中...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="collection-analysis">
<div className="analysis-error">
<p>{error}</p>
<button onClick={loadAnalysis} className="retry-button">
</button>
</div>
</div>
);
}
if (!analysis) {
return (
<div className="collection-analysis">
<div className="analysis-empty">
<p></p>
<button onClick={loadAnalysis} className="analyze-button">
</button>
</div>
</div>
);
}
return (
<div className="collection-analysis">
<h3>🧠 AI </h3>
<div className="analysis-stats">
<div className="stat-card">
<div className="stat-value">{analysis.total_cards}</div>
<div className="stat-label"></div>
</div>
<div className="stat-card">
<div className="stat-value">{analysis.unique_cards}</div>
<div className="stat-label"></div>
</div>
<div className="stat-card">
<div className="stat-value">{analysis.collection_score}</div>
<div className="stat-label"></div>
</div>
</div>
<div className="rarity-distribution">
<h4></h4>
<div className="rarity-bars">
{Object.entries(analysis.rarity_distribution).map(([rarity, count]) => (
<div key={rarity} className="rarity-bar">
<span className="rarity-name">{rarity}</span>
<div className="bar-container">
<div
className={`bar bar-${rarity.toLowerCase()}`}
style={{ width: `${(count / analysis.total_cards) * 100}%` }}
></div>
</div>
<span className="rarity-count">{count}</span>
</div>
))}
</div>
</div>
{analysis.recommendations && analysis.recommendations.length > 0 && (
<div className="recommendations">
<h4>🎯 AI推奨</h4>
<ul>
{analysis.recommendations.map((rec, index) => (
<li key={index}>{rec}</li>
))}
</ul>
</div>
)}
<button onClick={loadAnalysis} className="refresh-analysis">
</button>
</div>
);
};

@@ -0,0 +1,134 @@
import React, { useState } from 'react'
import { atproto, collections } from '../api/atproto.js'
import { env } from '../config/env.js'
export default function CommentForm({ user, agent, onCommentPosted }) {
const [text, setText] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const handleSubmit = async (e) => {
e.preventDefault()
if (!text.trim()) return
setLoading(true)
setError(null)
try {
const currentUrl = window.location.href
const timestamp = new Date().toISOString()
// Create ai.syui.log record structure (new unified format)
const record = {
repo: user.did,
collection: env.collection,
rkey: `comment-${Date.now()}`,
record: {
$type: env.collection,
url: currentUrl, // Keep for backward compatibility
post: {
url: currentUrl,
date: timestamp,
slug: currentUrl.match(/\/posts\/([^/]+)/)?.[1] || new URL(currentUrl).pathname.split('/').pop()?.replace(/\.html$/, '') || '',
tags: [],
title: document.title || 'Comment',
language: 'ja'
},
text: text.trim(),
type: 'comment',
author: {
did: user.did,
handle: user.handle,
displayName: user.displayName,
avatar: user.avatar
},
createdAt: timestamp
}
}
// Post the record using the same API as ask-AI
await agent.api.com.atproto.repo.putRecord({
repo: record.repo,
collection: record.collection,
rkey: record.rkey,
record: record.record
})
// キャッシュを無効化
collections.invalidateCache(env.collection)
// Clear form
setText('')
// Notify parent component
if (onCommentPosted) {
onCommentPosted()
}
// Show success message briefly
setText('✓ ')
setTimeout(() => {
setText('')
}, 2000)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
if (!user) {
return (
<div style={{
textAlign: 'center',
padding: '40px',
color: 'var(--text-secondary)'
}}>
<p>atproto login</p>
</div>
)
}
return (
<div>
<h3>post</h3>
<form onSubmit={handleSubmit}>
<div className="form-group" style={{ marginBottom: '12px', padding: '8px', backgroundColor: 'var(--background-secondary)', borderRadius: '4px', fontSize: '0.9em' }}>
<strong>url:</strong> {window.location.href}
</div>
<div className="form-group">
<label htmlFor="comment-text">comment:</label>
<textarea
id="comment-text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="text..."
rows={4}
required
disabled={loading}
className="form-input form-textarea"
/>
</div>
{error && (
<div className="error-message">
err: {error}
</div>
)}
<div className="form-actions">
<button
type="submit"
disabled={loading || !text.trim()}
className={`btn ${loading ? 'btn-outline' : 'btn-primary'}`}
>
{loading ? 'posting...' : 'post'}
</button>
</div>
</form>
</div>
)
}

@@ -1,130 +0,0 @@
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Card } from './Card';
import { Card as CardType } from '../types/card';
import { atprotoOAuthService } from '../services/atproto-oauth';
import '../styles/GachaAnimation.css';
interface GachaAnimationProps {
card: CardType;
animationType: string;
onComplete: () => void;
}
export const GachaAnimation: React.FC<GachaAnimationProps> = ({
card,
animationType,
onComplete
}) => {
const [phase, setPhase] = useState<'opening' | 'revealing' | 'complete'>('opening');
const [showCard, setShowCard] = useState(false);
const [isSharing, setIsSharing] = useState(false);
useEffect(() => {
const timer1 = setTimeout(() => setPhase('revealing'), 1500);
const timer2 = setTimeout(() => {
setPhase('complete');
setShowCard(true);
}, 3000);
return () => {
clearTimeout(timer1);
clearTimeout(timer2);
};
}, [onComplete]);
const handleCardClick = () => {
if (showCard) {
onComplete();
}
};
const handleSaveToCollection = async (e: React.MouseEvent) => {
e.stopPropagation();
if (isSharing) return;
setIsSharing(true);
try {
await atprotoOAuthService.saveCardToCollection(card);
alert('カードデータをatprotoコレクションに保存しました');
} catch (error) {
// Failed to save card
alert('保存に失敗しました。認証が必要かもしれません。');
} finally {
setIsSharing(false);
}
};
const getEffectClass = () => {
switch (animationType) {
case 'unique':
return 'effect-unique';
case 'kira':
return 'effect-kira';
case 'rare':
return 'effect-rare';
default:
return 'effect-normal';
}
};
return (
<div className={`gacha-container ${getEffectClass()}`} onClick={handleCardClick}>
<AnimatePresence mode="wait">
{phase === 'opening' && (
<motion.div
key="opening"
className="gacha-opening"
initial={{ scale: 0, rotate: -180 }}
animate={{ scale: 1, rotate: 0 }}
exit={{ scale: 0, opacity: 0 }}
transition={{ duration: 0.8, type: "spring" }}
>
<div className="gacha-pack">
<div className="pack-glow" />
</div>
</motion.div>
)}
{phase === 'revealing' && (
<motion.div
key="revealing"
initial={{ scale: 0, rotateY: 180 }}
animate={{ scale: 1, rotateY: 0 }}
transition={{ duration: 0.8, type: "spring" }}
>
<Card card={card} isRevealing={true} />
</motion.div>
)}
{phase === 'complete' && showCard && (
<motion.div
key="complete"
initial={{ scale: 1, rotateY: 0 }}
animate={{ scale: 1, rotateY: 0 }}
className="card-final"
>
<Card card={card} isRevealing={false} />
<div className="card-actions">
<button
className="save-button"
onClick={handleSaveToCollection}
disabled={isSharing}
>
{isSharing ? '保存中...' : '💾 atprotoに保存'}
</button>
<div className="click-hint"></div>
</div>
</motion.div>
)}
</AnimatePresence>
{animationType === 'unique' && (
<div className="unique-effect">
<div className="unique-particles" />
<div className="unique-burst" />
</div>
)}
</div>
);
};

@@ -1,144 +0,0 @@
import React, { useState, useEffect } from 'react';
import { cardApi, aiCardApi } from '../services/api';
import '../styles/GachaStats.css';
interface GachaStatsData {
total_draws: number;
cards_by_rarity: Record<string, number>;
success_rates: Record<string, number>;
recent_activity: Array<{
timestamp: string;
user_did: string;
card_name: string;
rarity: string;
}>;
}
export const GachaStats: React.FC = () => {
const [stats, setStats] = useState<GachaStatsData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [useAI, setUseAI] = useState(true);
const loadStats = async () => {
setLoading(true);
setError(null);
try {
let result;
if (useAI) {
try {
result = await aiCardApi.getEnhancedStats();
} catch (aiError) {
// AI stats unavailable, using basic stats
setUseAI(false);
result = await cardApi.getGachaStats();
}
} else {
result = await cardApi.getGachaStats();
}
setStats(result);
} catch (err) {
// Gacha stats failed
setError('統計データの取得に失敗しました。ai.cardサーバーが起動していることを確認してください。');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadStats();
}, []);
if (loading) {
return (
<div className="gacha-stats">
<div className="stats-loading">
<div className="loading-spinner"></div>
<p>...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="gacha-stats">
<div className="stats-error">
<p>{error}</p>
<button onClick={loadStats} className="retry-button">
</button>
</div>
</div>
);
}
if (!stats) {
return (
<div className="gacha-stats">
<div className="stats-empty">
<p></p>
<button onClick={loadStats} className="load-stats-button">
</button>
</div>
</div>
);
}
return (
<div className="gacha-stats">
<h3>📊 </h3>
<div className="stats-overview">
<div className="overview-card">
<div className="overview-value">{stats.total_draws}</div>
<div className="overview-label"></div>
</div>
</div>
<div className="rarity-stats">
<h4></h4>
<div className="rarity-grid">
{Object.entries(stats.cards_by_rarity).map(([rarity, count]) => (
<div key={rarity} className={`rarity-stat rarity-${rarity.toLowerCase()}`}>
<div className="rarity-count">{count}</div>
<div className="rarity-name">{rarity}</div>
{stats.success_rates[rarity] && (
<div className="success-rate">
{(stats.success_rates[rarity] * 100).toFixed(1)}%
</div>
)}
</div>
))}
</div>
</div>
{stats.recent_activity && stats.recent_activity.length > 0 && (
<div className="recent-activity">
<h4></h4>
<div className="activity-list">
{stats.recent_activity.slice(0, 5).map((activity, index) => (
<div key={index} className="activity-item">
<div className="activity-time">
{new Date(activity.timestamp).toLocaleString()}
</div>
<div className="activity-details">
<span className={`card-rarity rarity-${activity.rarity.toLowerCase()}`}>
{activity.rarity}
</span>
<span className="card-name">{activity.card_name}</span>
</div>
</div>
))}
</div>
</div>
)}
<button onClick={loadStats} className="refresh-stats">
</button>
</div>
);
};

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

@@ -1,203 +0,0 @@
import React, { useState } from 'react';
import { motion } from 'framer-motion';
import { authService } from '../services/auth';
import { atprotoOAuthService } from '../services/atproto-oauth';
import '../styles/Login.css';
interface LoginProps {
onLogin: (did: string, handle: string) => void;
onClose: () => void;
defaultHandle?: string;
}
export const Login: React.FC<LoginProps> = ({ onLogin, onClose, defaultHandle }) => {
const [loginMode, setLoginMode] = useState<'oauth' | 'legacy'>('oauth');
const [identifier, setIdentifier] = useState(defaultHandle || '');
const [password, setPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleOAuthLogin = async () => {
setError(null);
setIsLoading(true);
try {
// Prompt for handle if not provided
const handle = identifier.trim() || undefined;
await atprotoOAuthService.initiateOAuthFlow(handle);
// OAuth flow will redirect, so we don't need to handle the response here
} catch (err) {
setError('OAuth認証の開始に失敗しました。');
setIsLoading(false);
}
};
const handleLegacyLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setIsLoading(true);
try {
const response = await authService.login(identifier, password);
onLogin(response.did, response.handle);
} catch (err) {
setError('ログインに失敗しました。認証情報を確認してください。');
} finally {
setIsLoading(false);
}
};
return (
<motion.div
className="login-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
>
<motion.div
className="login-modal"
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: "spring", duration: 0.5 }}
onClick={(e) => e.stopPropagation()}
>
<h2>atprotoログイン</h2>
<div className="login-mode-selector">
<button
type="button"
className={`mode-button ${loginMode === 'oauth' ? 'active' : ''}`}
onClick={() => setLoginMode('oauth')}
>
OAuth 2.1 ()
</button>
<button
type="button"
className={`mode-button ${loginMode === 'legacy' ? 'active' : ''}`}
onClick={() => setLoginMode('legacy')}
>
</button>
</div>
{loginMode === 'oauth' ? (
<div className="oauth-login">
<div className="oauth-info">
<h3>🔐 OAuth 2.1 </h3>
<p>
atproto認証サーバーにリダイレクトされます
</p>
{(window.location.hostname === '127.0.0.1' || window.location.hostname === 'localhost') && (
<div className="dev-notice">
<small>🛠 開発環境: モック認証を使用しますBlueskyにはアクセスしません</small>
</div>
)}
</div>
<div className="form-group">
<label htmlFor="oauth-identifier">Bluesky Handle</label>
<input
id="oauth-identifier"
type="text"
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
placeholder="your.handle.bsky.social"
required
disabled={isLoading}
/>
</div>
{error && (
<div className="error-message">{error}</div>
)}
<div className="button-group">
<button
type="button"
className="oauth-login-button"
onClick={handleOAuthLogin}
disabled={isLoading || !identifier.trim()}
>
{isLoading ? '認証開始中...' : 'atprotoで認証'}
</button>
<button
type="button"
className="cancel-button"
onClick={onClose}
disabled={isLoading}
>
</button>
</div>
</div>
) : (
<form onSubmit={handleLegacyLogin}>
<div className="form-group">
<label htmlFor="identifier"> DID</label>
<input
id="identifier"
type="text"
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
placeholder="your.handle または did:plc:..."
required
disabled={isLoading}
/>
</div>
<div className="form-group">
<label htmlFor="password"></label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="アプリパスワード"
required
disabled={isLoading}
/>
<small>
<a href={`${import.meta.env.VITE_ATPROTO_WEB_URL || 'https://bsky.app'}/settings/app-passwords`} target="_blank" rel="noopener noreferrer">
</a>
使
</small>
</div>
{error && (
<div className="error-message">{error}</div>
)}
<div className="button-group">
<button
type="submit"
className="login-button"
disabled={isLoading}
>
{isLoading ? 'ログイン中...' : 'ログイン'}
</button>
<button
type="button"
className="cancel-button"
onClick={onClose}
disabled={isLoading}
>
</button>
</div>
</form>
)}
<div className="login-info">
<p>
ai.logはatprotoアカウントを使用します
PDSに保存されます
</p>
</div>
</motion.div>
</motion.div>
);
};

@@ -0,0 +1,36 @@
import React from 'react'
export default function OAuthCallback() {
return (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '50vh',
padding: '40px',
textAlign: 'center'
}}>
<div style={{
width: '50px',
height: '50px',
border: '4px solid #f3f3f3',
borderTop: '4px solid #667eea',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
marginBottom: '20px'
}} />
<h2 style={{ color: '#333', marginBottom: '12px' }}>OAuth認証処理中...</h2>
<p style={{ color: '#666', fontSize: '14px' }}>
認証が完了しましたら自動で元のページに戻ります
</p>
<style jsx>{`
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`}</style>
</div>
)
}

@@ -1,228 +0,0 @@
import React, { useEffect, useState } from 'react';
import { atprotoOAuthService } from '../services/atproto-oauth';
interface OAuthCallbackProps {
onSuccess: (did: string, handle: string) => void;
onError: (error: string) => void;
}
export const OAuthCallback: React.FC<OAuthCallbackProps> = ({ onSuccess, onError }) => {
const [isProcessing, setIsProcessing] = useState(true);
const [needsHandle, setNeedsHandle] = useState(false);
const [handle, setHandle] = useState('');
const [tempSession, setTempSession] = useState<any>(null);
useEffect(() => {
// Add timeout to prevent infinite loading
const timeoutId = setTimeout(() => {
onError('OAuth認証がタイムアウトしました');
}, 10000); // 10 second timeout
const handleCallback = async () => {
try {
// Handle both query params (?) and hash params (#)
const hashParams = new URLSearchParams(window.location.hash.substring(1));
const queryParams = new URLSearchParams(window.location.search);
// Try hash first (Bluesky uses this), then fallback to query
const code = hashParams.get('code') || queryParams.get('code');
const state = hashParams.get('state') || queryParams.get('state');
const error = hashParams.get('error') || queryParams.get('error');
const iss = hashParams.get('iss') || queryParams.get('iss');
if (error) {
throw new Error(`OAuth error: ${error}`);
}
if (!code || !state) {
throw new Error('Missing OAuth parameters');
}
// Use the official BrowserOAuthClient to handle the callback
const result = await atprotoOAuthService.handleOAuthCallback();
if (result) {
// Success - notify parent component
onSuccess(result.did, result.handle);
} else {
throw new Error('OAuth callback did not return a session');
}
} catch (error) {
// Even if OAuth fails, try to continue with a fallback approach
try {
// Create a minimal session to allow the user to proceed
const fallbackSession = {
did: 'did:plc:uqzpqmrjnptsxezjx4xuh2mn',
handle: 'syui.ai'
};
// Notify success with fallback session
onSuccess(fallbackSession.did, fallbackSession.handle);
} catch (fallbackError) {
onError(error instanceof Error ? error.message : 'OAuth認証に失敗しました');
}
} finally {
clearTimeout(timeoutId); // Clear timeout on completion
setIsProcessing(false);
}
};
handleCallback();
// Cleanup function
return () => {
clearTimeout(timeoutId);
};
}, [onSuccess, onError]);
const handleSubmitHandle = async (e?: React.FormEvent) => {
if (e) e.preventDefault();
const trimmedHandle = handle.trim();
if (!trimmedHandle) {
return;
}
setIsProcessing(true);
try {
// Resolve DID from handle
const did = await atprotoOAuthService.resolveDIDFromHandle(trimmedHandle);
// Update session with resolved DID and handle
const updatedSession = {
...tempSession,
did: did,
handle: trimmedHandle
};
// Save updated session
atprotoOAuthService.saveSessionToStorage(updatedSession);
// Success - notify parent component
onSuccess(did, trimmedHandle);
} catch (error) {
setIsProcessing(false);
onError(error instanceof Error ? error.message : 'ハンドルからDIDの解決に失敗しました');
}
};
if (needsHandle) {
return (
<div className="oauth-callback">
<div className="oauth-processing">
<h2>Blueskyハンドルを入力してください</h2>
<p>OAuth認証は成功しました</p>
<p style={{ fontSize: '12px', color: '#888', marginTop: '10px' }}>
: {handle || '(未入力)'} | : {handle.length}
</p>
<form onSubmit={handleSubmitHandle}>
<input
type="text"
value={handle}
onChange={(e) => {
setHandle(e.target.value);
}}
placeholder="例: syui.ai または user.bsky.social"
autoFocus
style={{
width: '100%',
padding: '10px',
marginTop: '20px',
marginBottom: '20px',
borderRadius: '8px',
border: '1px solid #ccc',
fontSize: '16px',
backgroundColor: '#1a1a1a',
color: 'white'
}}
/>
<button
type="submit"
disabled={!handle.trim() || isProcessing}
style={{
padding: '12px 24px',
backgroundColor: handle.trim() ? '#667eea' : '#444',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: handle.trim() ? 'pointer' : 'not-allowed',
fontSize: '16px',
fontWeight: 'bold',
transition: 'all 0.3s ease',
width: '100%'
}}
>
{isProcessing ? '処理中...' : '続行'}
</button>
</form>
</div>
</div>
);
}
if (isProcessing) {
return (
<div className="oauth-callback">
<div className="oauth-processing">
<div className="loading-spinner"></div>
</div>
</div>
);
}
return null;
};
// CSS styles (inline for simplicity)
const styles = `
.oauth-callback {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(180deg, #f8f9fa 0%, #ffffff 100%);
color: #333;
z-index: 9999;
}
.oauth-processing {
text-align: center;
padding: 40px;
background: rgba(255, 255, 255, 0.8);
border-radius: 16px;
backdrop-filter: blur(10px);
border: 1px solid rgba(0, 0, 0, 0.1);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
}
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(0, 0, 0, 0.1);
border-top: 3px solid #1185fe;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`;
// Inject styles
const styleSheet = document.createElement('style');
styleSheet.type = 'text/css';
styleSheet.innerText = styles;
document.head.appendChild(styleSheet);

@@ -1,36 +0,0 @@
import React, { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { OAuthCallback } from './OAuthCallback';
export const OAuthCallbackPage: React.FC = () => {
const navigate = useNavigate();
useEffect(() => {
}, []);
const handleSuccess = (did: string, handle: string) => {
// Add a small delay to ensure state is properly updated
setTimeout(() => {
navigate('/', { replace: true });
}, 100);
};
const handleError = (error: string) => {
// Add a small delay before redirect
setTimeout(() => {
navigate('/', { replace: true });
}, 2000); // Give user time to see error
};
return (
<div>
<h2>Processing OAuth callback...</h2>
<OAuthCallback
onSuccess={handleSuccess}
onError={handleError}
/>
</div>
);
};

@@ -0,0 +1,165 @@
import React, { useState } from 'react'
import { atproto, collections } from '../api/atproto.js'
import { env } from '../config/env.js'
const ProfileForm = ({ user, agent, apiConfig, onProfilePosted }) => {
const [text, setText] = useState('')
const [type, setType] = useState('user')
const [handle, setHandle] = useState('')
const [rkey, setRkey] = useState('')
const [posting, setPosting] = useState(false)
const [error, setError] = useState('')
const handleSubmit = async (e) => {
e.preventDefault()
if (!text.trim() || !handle.trim() || !rkey.trim()) {
setError('すべてのフィールドを入力してください')
return
}
setPosting(true)
setError('')
try {
// Get handle information
let authorData
try {
const handleDid = await atproto.getDid(apiConfig.pds, handle)
// Use agent to get profile with authentication
const profileResponse = await agent.api.app.bsky.actor.getProfile({ actor: handleDid })
authorData = profileResponse.data
} catch (err) {
throw new Error('ハンドルが見つかりません')
}
// Create record using the same pattern as CommentForm
const timestamp = new Date().toISOString()
const record = {
repo: user.did,
collection: env.collection,
rkey: rkey,
record: {
$type: env.collection,
text: text,
type: 'profile',
profileType: type, // admin or user
author: {
did: authorData.did,
handle: authorData.handle,
displayName: authorData.displayName || authorData.handle,
avatar: authorData.avatar || null
},
createdAt: timestamp,
post: {
url: window.location.origin,
date: timestamp,
slug: '',
tags: [],
title: 'Profile',
language: 'ja'
}
}
}
// Post the record using agent like CommentForm
await agent.api.com.atproto.repo.putRecord(record)
// Invalidate cache and refresh
collections.invalidateCache(env.collection)
// Reset form
setText('')
setType('user')
setHandle('')
setRkey('')
if (onProfilePosted) {
onProfilePosted()
}
} catch (err) {
console.error('Failed to create profile:', err)
setError(err.message || 'プロフィールの作成に失敗しました')
} finally {
setPosting(false)
}
}
if (!user) {
return null
}
return (
<div className="profile-form-container">
<h3>プロフィール投稿</h3>
{error && (
<div className="error-message">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="profile-form">
<div className="form-row">
<div className="form-group">
<label htmlFor="handle">ハンドル</label>
<input
type="text"
id="handle"
value={handle}
onChange={(e) => setHandle(e.target.value)}
placeholder="例: syui.ai"
required
/>
</div>
<div className="form-group">
<label htmlFor="rkey">Rkey</label>
<input
type="text"
id="rkey"
value={rkey}
onChange={(e) => setRkey(e.target.value)}
placeholder="例: syui"
required
/>
</div>
</div>
<div className="form-group">
<label htmlFor="type">タイプ</label>
<select
id="type"
value={type}
onChange={(e) => setType(e.target.value)}
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<div className="form-group">
<label htmlFor="text">プロフィールテキスト</label>
<textarea
id="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="プロフィールの説明を入力してください"
rows={4}
required
/>
</div>
<button
type="submit"
disabled={posting || !text.trim() || !handle.trim() || !rkey.trim()}
className="submit-btn"
>
{posting ? '投稿中...' : '投稿'}
</button>
</form>
</div>
)
}
export default ProfileForm

@@ -0,0 +1,136 @@
import React, { useState } from 'react'
// Helper function to get correct web URL based on avatar URL
function getCorrectWebUrl(avatarUrl) {
if (!avatarUrl) return 'https://bsky.app'
// If avatar is from bsky.app (main Bluesky), use bsky.app
if (avatarUrl.includes('cdn.bsky.app') || avatarUrl.includes('bsky.app')) {
return 'https://bsky.app'
}
// If avatar is from syu.is, use web.syu.is
if (avatarUrl.includes('bsky.syu.is') || avatarUrl.includes('syu.is')) {
return 'https://syu.is'
}
// Default to bsky.app
return 'https://bsky.app'
}
export default function ProfileRecordList({ profileRecords, apiConfig, user = null, agent = null, onRecordDeleted = null }) {
const [expandedRecords, setExpandedRecords] = useState(new Set())
const toggleJsonView = (uri) => {
const newExpanded = new Set(expandedRecords)
if (newExpanded.has(uri)) {
newExpanded.delete(uri)
} else {
newExpanded.add(uri)
}
setExpandedRecords(newExpanded)
}
if (!profileRecords || profileRecords.length === 0) {
return (
<section>
<p>プロフィールがありません</p>
</section>
)
}
const handleDelete = async (profile) => {
if (!user || !agent || !profile.uri) return
const confirmed = window.confirm('このプロフィールを削除しますか?')
if (!confirmed) return
try {
const uriParts = profile.uri.split('/')
await agent.api.com.atproto.repo.deleteRecord({
repo: uriParts[2],
collection: uriParts[3],
rkey: uriParts[4]
})
if (onRecordDeleted) {
onRecordDeleted()
}
} catch (error) {
alert(`削除に失敗しました: ${error.message}`)
}
}
const canDelete = (profile) => {
if (!user || !agent || !profile.uri) return false
// Check if the record is in the current user's repository
const recordRepoDid = profile.uri.split('/')[2]
return recordRepoDid === user.did
}
return (
<section>
{profileRecords.map((profile) => (
<div key={profile.uri} className="chat-message comment-style">
<div className="message-header">
{profile.value.author?.avatar ? (
<img
src={profile.value.author.avatar}
alt={`${profile.value.author.displayName || profile.value.author.handle} avatar`}
className="avatar"
/>
) : (
<div className="avatar">
{(profile.value.author?.displayName || profile.value.author?.handle || '?').charAt(0).toUpperCase()}
</div>
)}
<div className="user-info">
<div className="display-name">
{profile.value.author?.displayName || profile.value.author?.handle}
{profile.value.profileType === 'admin' && (
<span className="admin-badge"> Admin</span>
)}
</div>
<div className="handle">
<a
href={`${getCorrectWebUrl(profile.value.author?.avatar)}/profile/${profile.value.author?.did}`}
target="_blank"
rel="noopener noreferrer"
className="handle-link"
>
@{profile.value.author?.handle}
</a>
</div>
</div>
<div className="record-actions">
<button
onClick={() => toggleJsonView(profile.uri)}
className={`btn btn-sm ${expandedRecords.has(profile.uri) ? 'btn-outline' : 'btn-primary'}`}
title="Show/Hide JSON"
>
{expandedRecords.has(profile.uri) ? 'hide' : 'json'}
</button>
{canDelete(profile) && (
<button
onClick={() => handleDelete(profile)}
className="btn btn-danger btn-sm"
title="Delete Profile"
>
delete
</button>
)}
</div>
</div>
{expandedRecords.has(profile.uri) && (
<div className="json-display">
<pre className="json-content">
{JSON.stringify(profile, null, 2)}
</pre>
</div>
)}
<div className="message-content">{profile.value.text}</div>
</div>
))}
</section>
)
}

@@ -0,0 +1,154 @@
import React, { useState } from 'react'
import AvatarImage from './AvatarImage.jsx'
import Avatar from './Avatar.jsx'
// Helper function to get correct web URL based on avatar URL
function getCorrectWebUrl(avatarUrl) {
if (!avatarUrl) return 'https://bsky.app'
// If avatar is from bsky.app (main Bluesky), use bsky.app
if (avatarUrl.includes('cdn.bsky.app') || avatarUrl.includes('bsky.app')) {
return 'https://bsky.app'
}
// If avatar is from syu.is, use web.syu.is
if (avatarUrl.includes('bsky.syu.is') || avatarUrl.includes('syu.is')) {
return 'https://syu.is'
}
// Default to bsky.app
return 'https://bsky.app'
}
export default function RecordList({ title, records, apiConfig, showTitle = true, user = null, agent = null, onRecordDeleted = null }) {
const [expandedRecords, setExpandedRecords] = useState(new Set())
const [deletingRecords, setDeletingRecords] = useState(new Set())
const toggleJsonView = (index) => {
const newExpanded = new Set(expandedRecords)
if (newExpanded.has(index)) {
newExpanded.delete(index)
} else {
newExpanded.add(index)
}
setExpandedRecords(newExpanded)
}
const handleDelete = async (record, index) => {
if (!user || !agent || !record.uri) return
const confirmed = window.confirm('このレコードを削除しますか?')
if (!confirmed) return
setDeletingRecords(prev => new Set([...prev, index]))
try {
// Extract repo, collection, rkey from URI
const uriParts = record.uri.split('/')
const repo = uriParts[2]
const collection = uriParts[3]
const rkey = uriParts[4]
await agent.com.atproto.repo.deleteRecord({
repo: repo,
collection: collection,
rkey: rkey
})
if (onRecordDeleted) {
onRecordDeleted()
}
} catch (error) {
alert(`削除に失敗しました: ${error.message}`)
} finally {
setDeletingRecords(prev => {
const newSet = new Set(prev)
newSet.delete(index)
return newSet
})
}
}
const canDelete = (record) => {
return user && agent && record.uri && record.value.author?.did === user.did
}
if (!records || records.length === 0) {
return (
<section>
{showTitle && <h3>{title} (0)</h3>}
<p>レコードがありません</p>
</section>
)
}
return (
<section>
{showTitle && <h3>{title} ({records.length})</h3>}
{records.map((record, i) => (
<div key={i} className="record-item">
<div className="record-header">
<AvatarImage record={record} size={40} />
<div className="user-info">
<div className="display-name">{record.value.author?.displayName || record.value.author?.handle}</div>
<div className="handle">
<a
href={`${getCorrectWebUrl(record.value.author?.avatar)}/profile/${record.value.author?.did}`}
target="_blank"
rel="noopener noreferrer"
className="handle-link"
>
@{record.value.author?.handle}
</a>
</div>
<div className="timestamp">{new Date(record.value.createdAt).toLocaleString()}</div>
</div>
<div className="record-actions">
<button
onClick={() => toggleJsonView(i)}
className={`btn btn-sm ${expandedRecords.has(i) ? 'btn-outline' : 'btn-primary'}`}
title="Show/Hide JSON"
>
{expandedRecords.has(i) ? 'hide' : 'json'}
</button>
{canDelete(record) && (
<button
onClick={() => handleDelete(record, i)}
disabled={deletingRecords.has(i)}
className="btn btn-danger btn-sm"
title="Delete Record"
>
{deletingRecords.has(i) ? 'deleting...' : 'delete'}
</button>
)}
</div>
</div>
<div className="record-meta">
{record.value.post?.url && (
<a
href={record.value.post.url}
target="_blank"
rel="noopener noreferrer"
className="record-url"
>
{record.value.post.url}
</a>
)}
</div>
{expandedRecords.has(i) && (
<div className="json-display">
<pre className="json-content">
{JSON.stringify(record, null, 2)}
</pre>
</div>
)}
<div className="record-content">{record.value.text || record.value.content}</div>
</div>
))}
</section>
)
}

@@ -0,0 +1,283 @@
import React, { useState, useEffect } from 'react'
import RecordList from './RecordList.jsx'
import ChatRecordList from './ChatRecordList.jsx'
import ProfileRecordList from './ProfileRecordList.jsx'
import LoadingSkeleton from './LoadingSkeleton.jsx'
import { logger } from '../utils/logger.js'
export default function RecordTabs({ langRecords, commentRecords, userComments, chatRecords, chatHasMore, onLoadMoreChat, userChatRecords, userChatLoading, baseRecords, apiConfig, pageContext, user = null, agent = null, onRecordDeleted = null }) {
// Check if current page has matching chat records (AI posts always have chat records)
const isAiPost = !pageContext.isTopPage && Array.isArray(chatRecords) && chatRecords.some(chatPair => {
const recordUrl = chatPair.question?.value?.post?.url
if (!recordUrl) return false
try {
const recordRkey = new URL(recordUrl).pathname.split('/').pop()?.replace(/\.html$/, '')
return recordRkey === pageContext.rkey
} catch {
return false
}
})
const [activeTab, setActiveTab] = useState(isAiPost ? 'collection' : 'profiles')
// Monitor activeTab changes
useEffect(() => {
logger.log('RecordTabs: activeTab changed to', activeTab)
}, [activeTab])
logger.log('RecordTabs: activeTab is', activeTab)
logger.log('RecordTabs: commentRecords prop:', commentRecords?.length || 0, commentRecords)
// Filter records based on page context
const filterRecords = (records, isProfile = false) => {
// Ensure records is an array
const recordsArray = Array.isArray(records) ? records : []
logger.log('filterRecords called with:', {
recordsLength: recordsArray.length,
isProfile,
isTopPage: pageContext.isTopPage,
pageRkey: pageContext.rkey,
records: recordsArray
})
if (pageContext.isTopPage) {
// Top page: show latest 3 records
const result = recordsArray.slice(0, 3)
logger.log('filterRecords: Top page result:', result.length, result)
return result
} else {
// Individual page: show records matching the URL
const filtered = recordsArray.filter(record => {
// Profile records should always be shown
if (isProfile || record.value?.type === 'profile') {
logger.log('filterRecords: Profile record included:', record.value?.type)
return true
}
const recordUrl = record.value?.post?.url
if (!recordUrl) {
logger.log('filterRecords: No recordUrl found for record:', record.value?.type)
return false
}
try {
const recordRkey = new URL(recordUrl).pathname.split('/').pop()?.replace(/\.html$/, '')
const matches = recordRkey === pageContext.rkey
logger.log('filterRecords: URL matching:', { recordRkey, pageRkey: pageContext.rkey, matches })
return matches
} catch {
logger.log('filterRecords: URL parsing failed for:', recordUrl)
return false
}
})
logger.log('filterRecords: Individual page result:', filtered.length, filtered)
return filtered
}
}
// Special filter for chat records (which are already processed into pairs)
const filterChatRecords = (chatPairs) => {
// Ensure chatPairs is an array
const chatArray = Array.isArray(chatPairs) ? chatPairs : []
logger.log('filterChatRecords called:', {
isTopPage: pageContext.isTopPage,
rkey: pageContext.rkey,
chatPairsLength: chatArray.length,
chatPairsType: typeof chatPairs,
isArray: Array.isArray(chatPairs)
})
if (pageContext.isTopPage) {
// Top page: show latest 3 pairs
const result = chatArray.slice(0, 3)
logger.log('Top page: returning', result.length, 'pairs')
return result
} else {
// Individual page: show pairs matching the URL (compare path only, ignore domain)
const filtered = chatArray.filter(chatPair => {
const recordUrl = chatPair.question?.value?.post?.url
if (!recordUrl) {
logger.log('No recordUrl for chatPair:', chatPair)
return false
}
try {
// Extract path from URL and get the filename part
const recordPath = new URL(recordUrl).pathname
const recordRkey = recordPath.split('/').pop()?.replace(/\.html$/, '')
logger.log('Comparing:', { recordRkey, pageRkey: pageContext.rkey, recordUrl })
// Compare with current page rkey
const matches = recordRkey === pageContext.rkey
if (matches) {
logger.log('Found matching chat pair!')
}
return matches
} catch (error) {
logger.log('Error processing recordUrl:', recordUrl, error)
return false
}
})
logger.log('Individual page: returning', filtered.length, 'filtered pairs')
return filtered
}
}
const filteredLangRecords = filterRecords(Array.isArray(langRecords) ? langRecords : [])
logger.log('RecordTabs: About to filter commentRecords:', commentRecords?.length || 0, commentRecords)
const filteredCommentRecords = filterRecords(Array.isArray(commentRecords) ? commentRecords : [])
logger.log('RecordTabs: After filtering commentRecords:', filteredCommentRecords.length, filteredCommentRecords)
const filteredUserComments = filterRecords(Array.isArray(userComments) ? userComments : [])
const filteredChatRecords = filterChatRecords(Array.isArray(chatRecords) ? chatRecords : [])
const filteredBaseRecords = filterRecords(Array.isArray(baseRecords) ? baseRecords : [])
logger.log('RecordTabs: filtered results:')
logger.log(' - filteredCommentRecords:', filteredCommentRecords.length, filteredCommentRecords)
logger.log(' - filteredLangRecords:', filteredLangRecords.length)
logger.log(' - filteredUserComments:', filteredUserComments.length)
logger.log(' - pageContext:', pageContext)
logger.log('RecordTabs: TAB RENDER VALUES:')
logger.log(' - filteredCommentRecords.length for tab:', filteredCommentRecords.length)
logger.log(' - commentRecords input:', commentRecords?.length || 0)
// Filter profile records from baseRecords
const profileRecords = (Array.isArray(baseRecords) ? baseRecords : []).filter(record => record.value?.type === 'profile')
const sortedProfileRecords = profileRecords.sort((a, b) => {
if (a.value.profileType === 'admin' && b.value.profileType !== 'admin') return -1
if (a.value.profileType !== 'admin' && b.value.profileType === 'admin') return 1
return 0
})
const filteredProfileRecords = filterRecords(sortedProfileRecords, true)
return (
<div className="record-tabs">
{!isAiPost && (
<div className="tab-header">
<button
className={`tab-btn ${activeTab === 'profiles' ? 'active' : ''}`}
onClick={() => {
logger.log('RecordTabs: Profiles tab clicked')
setActiveTab('profiles')
}}
>
about ({filteredProfileRecords.length})
</button>
<button
className={`tab-btn ${activeTab === 'collection' ? 'active' : ''}`}
onClick={() => setActiveTab('collection')}
>
chat ({filteredChatRecords.length > 0 ? filteredChatRecords.length : (userChatRecords?.length || 0)})
</button>
<button
className={`tab-btn ${activeTab === 'comment' ? 'active' : ''}`}
onClick={() => {
logger.log('RecordTabs: feedback tab clicked, setting activeTab to comment')
setActiveTab('comment')
}}
>
feedback ({(() => {
logger.log('RecordTabs: feedback tab render - filteredCommentRecords.length:', filteredCommentRecords.length)
return filteredCommentRecords.length
})()})
</button>
<button
className={`tab-btn ${activeTab === 'users' ? 'active' : ''}`}
onClick={() => setActiveTab('users')}
>
comment ({filteredUserComments.length})
</button>
<button
className={`tab-btn ${activeTab === 'lang' ? 'active' : ''}`}
onClick={() => setActiveTab('lang')}
>
en ({filteredLangRecords.length})
</button>
</div>
)}
<div className="tab-content">
{activeTab === 'lang' && !isAiPost && (
!langRecords ? (
<LoadingSkeleton count={3} showTitle={true} />
) : (
<RecordList
title=""
records={filteredLangRecords}
apiConfig={apiConfig}
user={user}
agent={agent}
onRecordDeleted={onRecordDeleted}
showTitle={false}
/>
)
)}
{activeTab === 'comment' && !isAiPost && (
!commentRecords ? (
<LoadingSkeleton count={3} showTitle={true} />
) : (
<RecordList
title=""
records={filteredCommentRecords}
apiConfig={apiConfig}
user={user}
agent={agent}
onRecordDeleted={onRecordDeleted}
showTitle={false}
/>
)
)}
{activeTab === 'collection' && (
userChatLoading ? (
<LoadingSkeleton count={2} showTitle={true} />
) : (
<ChatRecordList
chatPairs={filteredChatRecords.length > 0 ? filteredChatRecords : (Array.isArray(userChatRecords) ? userChatRecords : [])}
chatHasMore={filteredChatRecords.length > 0 ? chatHasMore : false}
onLoadMoreChat={filteredChatRecords.length > 0 ? onLoadMoreChat : null}
apiConfig={apiConfig}
user={user}
agent={agent}
onRecordDeleted={onRecordDeleted}
/>
)
)}
{activeTab === 'users' && !isAiPost && (
!userComments ? (
<LoadingSkeleton count={3} showTitle={true} />
) : (
<RecordList
title=""
records={filteredUserComments}
apiConfig={apiConfig}
user={user}
agent={agent}
onRecordDeleted={onRecordDeleted}
showTitle={false}
/>
)
)}
{activeTab === 'profiles' && !isAiPost && (
!baseRecords ? (
<LoadingSkeleton count={3} showTitle={true} />
) : (
<ProfileRecordList
profileRecords={filteredProfileRecords}
apiConfig={apiConfig}
user={user}
agent={agent}
onRecordDeleted={onRecordDeleted}
/>
)
)}
</div>
</div>
)
}

@@ -0,0 +1,531 @@
import React, { useState } from 'react'
import { env } from '../config/env.js'
import AvatarTestPanel from './AvatarTestPanel.jsx'
import AvatarTest from './AvatarTest.jsx'
export default function TestUI() {
const [activeTab, setActiveTab] = useState('putRecord')
const [accessJwt, setAccessJwt] = useState('')
const [handle, setHandle] = useState('')
const [sessionDid, setSessionDid] = useState('')
const [collection, setCollection] = useState('ai.syui.log')
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [success, setSuccess] = useState(null)
const [showJson, setShowJson] = useState(false)
const [lastRecord, setLastRecord] = useState(null)
const collections = [
'ai.syui.log',
'ai.syui.log.chat',
'ai.syui.log.chat.lang',
'ai.syui.log.chat.comment'
]
const generateDummyData = (collectionType) => {
const timestamp = new Date().toISOString()
const url = 'https://syui.ai/test/dummy'
const basePost = {
url: url,
date: timestamp,
slug: 'dummy-test',
tags: ['test', 'dummy'],
title: 'Test Post',
language: 'ja'
}
const baseAuthor = {
did: sessionDid || null, // Use real session DID if available, otherwise null
handle: handle || 'test.user',
displayName: 'Test User',
avatar: null
}
switch (collectionType) {
case 'ai.syui.log':
return {
$type: collectionType,
url: url,
post: basePost,
text: 'テストコメントです。これはダミーデータです。',
type: 'comment',
author: baseAuthor,
createdAt: timestamp
}
case 'ai.syui.log.chat':
const isQuestion = Math.random() > 0.5
return {
$type: collectionType,
post: basePost,
text: isQuestion ? 'これはテスト用の質問です。' : 'これはテスト用のAI回答です。詳しく説明します。',
type: isQuestion ? 'question' : 'answer',
author: isQuestion ? baseAuthor : {
did: 'did:plc:ai-test',
handle: 'ai.syui.ai',
displayName: 'ai',
avatar: null
},
createdAt: timestamp
}
case 'ai.syui.log.chat.lang':
return {
$type: collectionType,
post: basePost,
text: 'This is a test translation. Hello, this is a dummy English translation of the Japanese post.',
type: 'en',
author: {
did: 'did:plc:ai-test',
handle: 'ai.syui.ai',
displayName: 'ai',
avatar: null
},
createdAt: timestamp
}
case 'ai.syui.log.chat.comment':
return {
$type: collectionType,
post: basePost,
text: 'これはAIによるテストコメントです。記事についての感想や補足情報を提供します。',
author: {
did: 'did:plc:ai-test',
handle: 'ai.syui.ai',
displayName: 'ai',
avatar: null
},
createdAt: timestamp
}
default:
return {}
}
}
const handleSubmit = async (e) => {
e.preventDefault()
if (!accessJwt.trim() || !handle.trim()) {
setError('Access JWT and Handle are required')
return
}
setLoading(true)
setError(null)
setSuccess(null)
try {
const recordData = generateDummyData(collection)
const rkey = `test-${Date.now()}`
const record = {
repo: handle, // Use handle as is, without adding .bsky.social
collection: collection,
rkey: rkey,
record: recordData
}
setLastRecord(record)
// Direct API call with accessJwt
const response = await fetch(`https://${env.pds}/xrpc/com.atproto.repo.putRecord`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessJwt}`
},
body: JSON.stringify(record)
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(`API Error: ${response.status} - ${errorData.message || response.statusText}`)
}
const result = await response.json()
setSuccess(`Record created successfully! URI: ${result.uri}`)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
const handleDelete = async () => {
if (!lastRecord || !accessJwt.trim()) {
setError('No record to delete or missing access JWT')
return
}
setLoading(true)
setError(null)
try {
const deleteData = {
repo: lastRecord.repo,
collection: lastRecord.collection,
rkey: lastRecord.rkey
}
const response = await fetch(`https://${env.pds}/xrpc/com.atproto.repo.deleteRecord`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessJwt}`
},
body: JSON.stringify(deleteData)
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(`Delete Error: ${response.status} - ${errorData.message || response.statusText}`)
}
setSuccess('Record deleted successfully!')
setLastRecord(null)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
return (
<div className="test-ui">
<h2>🧪 Test UI</h2>
{/* Tab Navigation */}
<div className="test-tabs">
<button
onClick={() => setActiveTab('putRecord')}
className={`test-tab ${activeTab === 'putRecord' ? 'active' : ''}`}
>
Manual putRecord
</button>
<button
onClick={() => setActiveTab('avatar')}
className={`test-tab ${activeTab === 'avatar' ? 'active' : ''}`}
>
Avatar System
</button>
</div>
{activeTab === 'putRecord' && (
<div className="test-content">
<p className="description">
OAuth不要のテスト用UIaccessJwtとhandleを直接入力して各collectionにダミーデータを投稿できます
</p>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="access-jwt">Access JWT:</label>
<textarea
id="access-jwt"
value={accessJwt}
onChange={(e) => setAccessJwt(e.target.value)}
placeholder="eyJ... (Access JWT token)"
rows={3}
required
disabled={loading}
/>
</div>
<div className="form-group">
<label htmlFor="handle">Handle:</label>
<input
id="handle"
type="text"
value={handle}
onChange={(e) => setHandle(e.target.value)}
placeholder="user.bsky.social"
required
disabled={loading}
/>
</div>
<div className="form-group">
<label htmlFor="session-did">Session DID (optional):</label>
<input
id="session-did"
type="text"
value={sessionDid}
onChange={(e) => setSessionDid(e.target.value)}
placeholder="did:plc:xxxxx (Leave empty to use test DID)"
disabled={loading}
/>
</div>
<div className="form-group">
<label htmlFor="collection">Collection:</label>
<select
id="collection"
value={collection}
onChange={(e) => setCollection(e.target.value)}
disabled={loading}
>
{collections.map(col => (
<option key={col} value={col}>{col}</option>
))}
</select>
</div>
{error && (
<div className="error-message">
{error}
</div>
)}
{success && (
<div className="success-message">
{success}
</div>
)}
<div className="form-actions">
<button
type="submit"
disabled={loading || !accessJwt.trim() || !handle.trim()}
className="submit-btn"
>
{loading ? '⏳ Creating...' : '📤 Create Record'}
</button>
<button
type="button"
onClick={() => setShowJson(!showJson)}
className="json-btn"
disabled={loading}
>
{showJson ? '🙈 Hide JSON' : '👁️ Show JSON'}
</button>
{lastRecord && (
<button
type="button"
onClick={handleDelete}
className="delete-btn"
disabled={loading}
>
{loading ? '⏳ Deleting...' : '🗑️ Delete Last Record'}
</button>
)}
</div>
</form>
{showJson && (
<div className="json-preview">
<h3>Generated JSON:</h3>
<pre>{JSON.stringify(generateDummyData(collection), null, 2)}</pre>
</div>
)}
{lastRecord && (
<div className="last-record">
<h3>Last Created Record:</h3>
<div className="record-info">
<p><strong>Collection:</strong> {lastRecord.collection}</p>
<p><strong>RKey:</strong> {lastRecord.rkey}</p>
<p><strong>Repo:</strong> {lastRecord.repo}</p>
</div>
</div>
)}
</div>
)}
{activeTab === 'avatar' && (
<div className="test-content">
<AvatarTestPanel />
</div>
)}
<style jsx>{`
.test-ui {
border: 3px solid #ff6b6b;
border-radius: 8px;
padding: 20px;
margin: 20px 0;
background: #fff5f5;
}
.test-tabs {
display: flex;
gap: 10px;
margin-bottom: 20px;
border-bottom: 2px solid #ddd;
padding-bottom: 10px;
}
.test-tab {
background: #f8f9fa;
border: 1px solid #ddd;
padding: 8px 16px;
border-radius: 4px 4px 0 0;
cursor: pointer;
font-size: 14px;
font-weight: 600;
color: #666;
transition: all 0.2s;
}
.test-tab:hover {
background: #e9ecef;
color: #333;
}
.test-tab.active {
background: #ff6b6b;
color: white;
border-color: #ff6b6b;
}
.test-content {
margin-top: 20px;
}
.test-ui h2 {
color: #ff6b6b;
margin-top: 0;
}
.description {
color: #666;
font-style: italic;
margin-bottom: 20px;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
color: #333;
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
font-family: monospace;
}
.form-group textarea {
resize: vertical;
min-height: 80px;
}
.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
outline: none;
border-color: #ff6b6b;
box-shadow: 0 0 0 2px rgba(255, 107, 107, 0.25);
}
.form-group input:disabled,
.form-group textarea:disabled,
.form-group select:disabled {
background: #f8f9fa;
cursor: not-allowed;
}
.error-message {
background: #f8d7da;
color: #721c24;
padding: 10px;
border-radius: 4px;
margin-bottom: 15px;
border: 1px solid #f5c6cb;
}
.success-message {
background: #d4edda;
color: #155724;
padding: 10px;
border-radius: 4px;
margin-bottom: 15px;
border: 1px solid #c3e6cb;
}
.form-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 20px;
}
.submit-btn {
background: #ff6b6b;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
transition: background 0.2s;
}
.submit-btn:hover:not(:disabled) {
background: #ff5252;
}
.submit-btn:disabled {
background: #6c757d;
cursor: not-allowed;
}
.json-btn {
background: #17a2b8;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
transition: background 0.2s;
}
.json-btn:hover:not(:disabled) {
background: #138496;
}
.delete-btn {
background: #dc3545;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
transition: background 0.2s;
}
.delete-btn:hover:not(:disabled) {
background: #c82333;
}
.json-preview {
margin-top: 20px;
padding: 15px;
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
}
.json-preview h3 {
margin-top: 0;
color: #495057;
}
.json-preview pre {
background: #e9ecef;
padding: 10px;
border-radius: 4px;
overflow-x: auto;
font-size: 12px;
margin: 0;
}
.last-record {
margin-top: 20px;
padding: 15px;
background: #e7f3ff;
border: 1px solid #b3d9ff;
border-radius: 4px;
}
.last-record h3 {
margin-top: 0;
color: #0066cc;
}
.record-info p {
margin: 5px 0;
font-family: monospace;
font-size: 14px;
}
`}</style>
</div>
)
}

@@ -1,158 +0,0 @@
// Application configuration
export interface AppConfig {
adminDid: string;
adminHandle: string;
aiDid: string;
aiHandle: string;
aiDisplayName: string;
aiAvatar: string;
aiDescription: string;
collections: {
base: string; // Base collection like "ai.syui.log"
};
host: string;
rkey?: string; // Current post rkey if on post page
aiEnabled: boolean;
aiAskAi: boolean;
aiProvider: string;
aiModel: string;
aiHost: string;
aiSystemPrompt: string;
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 generateBaseCollectionFromHost(host: string): string {
try {
// Remove protocol if present
const cleanHost = host.replace(/^https?:\/\//, '');
// Split host into parts
const parts = cleanHost.split('.');
if (parts.length < 2) {
throw new Error('Invalid host format');
}
// Reverse the parts for collection naming
// log.syui.ai -> ai.syui.log
const reversedParts = parts.reverse();
const result = reversedParts.join('.');
return result;
} catch (error) {
// Fallback to default
return 'ai.syui.log';
}
}
// Extract rkey from current URL
// /posts/xxx -> xxx (remove .html if present)
function extractRkeyFromUrl(): string | undefined {
const pathname = window.location.pathname;
const match = pathname.match(/\/posts\/([^/]+)\/?$/);
if (match) {
// Remove .html extension if present
return match[1].replace(/\.html$/, '');
}
return undefined;
}
// Get application configuration from environment variables
export function getAppConfig(): AppConfig {
const host = import.meta.env.VITE_APP_HOST || 'https://log.syui.ai';
const 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 autoGeneratedBase = generateBaseCollectionFromHost(host);
let baseCollection = import.meta.env.VITE_OAUTH_COLLECTION || autoGeneratedBase;
// Ensure base collection is never undefined
if (!baseCollection) {
baseCollection = 'ai.syui.log';
}
const collections = {
base: baseCollection,
};
const rkey = extractRkeyFromUrl();
// AI configuration
const aiEnabled = import.meta.env.VITE_AI_ENABLED === 'true';
const aiAskAi = import.meta.env.VITE_AI_ASK_AI === 'true';
const aiProvider = import.meta.env.VITE_AI_PROVIDER || 'ollama';
const aiModel = import.meta.env.VITE_AI_MODEL || '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';
// 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,
aiEnabled,
aiAskAi,
aiProvider,
aiModel,
aiHost,
aiSystemPrompt,
allowedHandles,
atprotoPds,
bskyPublicApi,
atprotoApi
};
}
// Export singleton instance
export const appConfig = getAppConfig();

@@ -0,0 +1,195 @@
import { useState, useEffect } from 'react'
import { atproto, collections } from '../api/atproto.js'
import { getApiConfig } from '../utils/pds.js'
import { env } from '../config/env.js'
import { getErrorMessage } from '../utils/errorHandler.js'
import { logger } from '../utils/logger.js'
export function useAdminData() {
const [adminData, setAdminData] = useState({
did: '',
profile: null,
records: [],
apiConfig: null
})
const [langRecords, setLangRecords] = useState([])
const [commentRecords, setCommentRecords] = useState([])
const [chatRecords, setChatRecords] = useState([])
const [chatCursor, setChatCursor] = useState(null)
const [chatHasMore, setChatHasMore] = useState(true)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
loadAdminData()
}, [])
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 with error handling
logger.log('useAdminData: Starting API calls...')
const [records, lang, comment, chatResult] = await Promise.all([
collections.getBase(apiConfig.pds, did, env.collection).catch(err => {
logger.error('getBase error:', err)
throw err
}),
collections.getLang(apiConfig.pds, did, env.collection).catch(err => {
logger.error('getLang error:', err)
throw err
}),
collections.getComment(apiConfig.pds, did, env.collection).catch(err => {
logger.error('getComment error:', err)
throw err
}),
collections.getChat(apiConfig.pds, did, env.collection, 10).catch(err => {
logger.error('getChat error:', err)
throw err
})
])
logger.log('useAdminData: API calls completed successfully')
const chat = chatResult.records || chatResult
const cursor = chatResult.cursor || null
setChatCursor(cursor)
setChatHasMore(!!cursor)
logger.log('useAdminData: chatResult structure:', chatResult)
logger.log('useAdminData: chat variable type:', typeof chat, 'isArray:', Array.isArray(chat))
// Process chat records into question-answer pairs
const chatPairs = []
const recordMap = new Map()
// Ensure chat is an array
const chatArray = Array.isArray(chat) ? chat : []
// First pass: organize records by base rkey
chatArray.forEach(record => {
const rkey = record.uri.split('/').pop()
const baseRkey = rkey.replace('-answer', '')
if (!recordMap.has(baseRkey)) {
recordMap.set(baseRkey, { question: null, answer: null })
}
if (record.value.type === 'question') {
recordMap.get(baseRkey).question = record
} else if (record.value.type === 'answer') {
recordMap.get(baseRkey).answer = record
}
})
// Second pass: create chat pairs
recordMap.forEach((pair, rkey) => {
if (pair.question) {
chatPairs.push({
rkey,
question: pair.question,
answer: pair.answer,
createdAt: pair.question.value.createdAt
})
}
})
// Sort by creation time (newest first)
chatPairs.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
logger.log('useAdminData: raw chat records:', chat.length)
logger.log('useAdminData: processed chat pairs:', chatPairs.length, chatPairs)
logger.log('useAdminData: setting state data:')
logger.log(' - records:', records.length)
logger.log(' - langRecords:', lang.length)
logger.log(' - commentRecords:', comment.length, comment)
logger.log(' - chatRecords:', chatPairs.length)
setAdminData({ did, profile, records, apiConfig })
setLangRecords(lang)
setCommentRecords(comment)
setChatRecords(chatPairs)
} catch (err) {
// Log the actual error for debugging
logger.error('useAdminData: Error in loadAdminData:', err)
setError('silent_failure')
} finally {
setLoading(false)
}
}
const loadMoreChat = async () => {
if (!chatCursor || !chatHasMore) return
try {
const apiConfig = getApiConfig(`https://${env.pds}`)
const did = await atproto.getDid(env.pds, env.admin)
const chatResult = await collections.getChat(apiConfig.pds, did, env.collection, 10, chatCursor)
const newChatRecords = chatResult.records || chatResult
const newCursor = chatResult.cursor || null
// Process new chat records into question-answer pairs
const newChatPairs = []
const recordMap = new Map()
// Ensure newChatRecords is an array
const newChatArray = Array.isArray(newChatRecords) ? newChatRecords : []
// First pass: organize records by base rkey
newChatArray.forEach(record => {
const rkey = record.uri.split('/').pop()
const baseRkey = rkey.replace('-answer', '')
if (!recordMap.has(baseRkey)) {
recordMap.set(baseRkey, { question: null, answer: null })
}
if (record.value.type === 'question') {
recordMap.get(baseRkey).question = record
} else if (record.value.type === 'answer') {
recordMap.get(baseRkey).answer = record
}
})
// Second pass: create chat pairs
recordMap.forEach((pair, rkey) => {
if (pair.question) {
newChatPairs.push({
rkey,
question: pair.question,
answer: pair.answer,
createdAt: pair.question.value.createdAt
})
}
})
// Sort new pairs by creation time (newest first)
newChatPairs.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
// Append to existing chat records
setChatRecords(prev => [...prev, ...newChatPairs])
setChatCursor(newCursor)
setChatHasMore(!!newCursor)
} catch (err) {
// Silently fail - no error logging
}
}
return {
adminData,
langRecords,
commentRecords,
chatRecords,
chatHasMore,
loading,
error,
refresh: loadAdminData,
loadMoreChat
}
}

230
oauth/src/hooks/useAskAI.js Normal file

@@ -0,0 +1,230 @@
import { useState } from 'react'
import { atproto, collections } from '../api/atproto.js'
import { env } from '../config/env.js'
import { logger } from '../utils/logger.js'
import { getErrorMessage, logError } from '../utils/errorHandler.js'
import { AIProviderFactory } from '../services/aiProvider.js'
export function useAskAI(adminData, userProfile, agent) {
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [chatHistory, setChatHistory] = useState([])
// AIプロバイダーを環境変数から作成
const aiProvider = AIProviderFactory.createFromEnv()
const askQuestion = async (question) => {
if (!question.trim()) return
setLoading(true)
setError(null)
try {
logger.log('Sending question to AI provider:', question)
// AIプロバイダーに質問を送信
const aiResponse = await aiProvider.ask(question, {
userProfile: userProfile
})
logger.log('Received AI response:', aiResponse)
// AI回答をチャット履歴に追加
const chatEntry = {
id: `chat-${Date.now()}`,
question: question.trim(),
answer: aiResponse.answer || 'エラーが発生しました',
timestamp: new Date().toISOString(),
user: userProfile ? {
did: userProfile.did,
handle: userProfile.handle,
displayName: userProfile.displayName,
avatar: userProfile.avatar
} : null
}
setChatHistory(prev => [...prev, chatEntry])
// atprotoにレコードを保存
await saveChatRecord(chatEntry, aiResponse)
// Dispatch event for blog communication
window.dispatchEvent(new CustomEvent('aiResponseReceived', {
detail: {
question: chatEntry.question,
answer: chatEntry.answer,
timestamp: chatEntry.timestamp,
aiProfile: adminData?.profile ? {
did: adminData.did,
handle: adminData.profile.handle,
displayName: adminData.profile.displayName,
avatar: adminData.profile.avatar
} : null
}
}))
return aiResponse
} catch (err) {
logError(err, 'useAskAI.askQuestion')
let errorMessage = 'AI応答の生成に失敗しました'
if (err.message.includes('Request timeout')) {
errorMessage = 'AI応答がタイムアウトしました'
} else if (err.message.includes('API error')) {
errorMessage = `API エラー: ${err.message}`
} else if (err.message.includes('Failed to fetch')) {
errorMessage = 'AI サーバーに接続できませんでした'
}
setError(errorMessage)
throw err
} finally {
setLoading(false)
}
}
const saveChatRecord = async (chatEntry, aiResponse) => {
if (!agent || !adminData?.did) {
logger.warn('Cannot save chat record: missing agent or admin data')
return
}
try {
const currentUrl = window.location.href
const timestamp = chatEntry.timestamp
const baseRkey = `${new Date(timestamp).toISOString().replace(/[:.]/g, '-').slice(0, -5)}Z`
// Post metadata (共通)
const postMetadata = {
url: currentUrl,
date: timestamp,
slug: new URL(currentUrl).pathname.split('/').pop()?.replace(/\.html$/, '') || '',
tags: [],
title: document.title || 'AI Chat',
language: 'ja'
}
// Question record (ユーザーの質問)
const questionRecord = {
repo: adminData.did,
collection: `${env.collection}.chat`,
rkey: baseRkey,
record: {
$type: `${env.collection}.chat`,
post: postMetadata,
text: chatEntry.question,
type: 'question',
author: chatEntry.user ? {
did: chatEntry.user.did,
handle: chatEntry.user.handle,
displayName: chatEntry.user.displayName,
avatar: chatEntry.user.avatar
} : {
did: 'unknown',
handle: 'user',
displayName: 'User',
avatar: null
},
createdAt: timestamp
}
}
// Answer record (AIの回答)
const answerRecord = {
repo: adminData.did,
collection: `${env.collection}.chat`,
rkey: `${baseRkey}-answer`,
record: {
$type: `${env.collection}.chat`,
post: postMetadata,
text: chatEntry.answer,
type: 'answer',
author: {
did: adminData.did,
handle: adminData.profile?.handle || 'ai',
displayName: adminData.profile?.displayName || 'ai',
avatar: adminData.profile?.avatar || null
},
createdAt: timestamp
}
}
logger.log('Saving question record to atproto:', questionRecord)
await atproto.putRecord(null, questionRecord, agent)
logger.log('Saving answer record to atproto:', answerRecord)
await atproto.putRecord(null, answerRecord, agent)
// キャッシュを無効化
collections.invalidateCache(env.collection)
logger.log('Chat records saved successfully')
} catch (err) {
logError(err, 'useAskAI.saveChatRecord')
// 保存エラーは致命的ではないので、UIエラーにはしない
}
}
const clearChatHistory = () => {
setChatHistory([])
setError(null)
}
const loadChatHistory = async () => {
if (!adminData?.did) return
try {
const records = await collections.getChat(
adminData.apiConfig.pds,
adminData.did,
env.collection
)
// Group records by timestamp and create Q&A pairs
const recordGroups = {}
records.forEach(record => {
const timestamp = record.value.createdAt
const baseKey = timestamp.replace('-answer', '')
if (!recordGroups[baseKey]) {
recordGroups[baseKey] = {}
}
if (record.value.type === 'question') {
recordGroups[baseKey].question = record.value.text
recordGroups[baseKey].user = record.value.author
recordGroups[baseKey].timestamp = timestamp
recordGroups[baseKey].id = record.uri
} else if (record.value.type === 'answer') {
recordGroups[baseKey].answer = record.value.text
recordGroups[baseKey].timestamp = timestamp
}
})
// Convert to history format, only include complete Q&A pairs
const history = Object.values(recordGroups)
.filter(group => group.question && group.answer)
.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp))
.slice(-10) // 最新10件のみ
setChatHistory(history)
logger.log('Chat history loaded:', history.length, 'entries')
} catch (err) {
logError(err, 'useAskAI.loadChatHistory')
// 履歴読み込みエラーは致命的ではない
}
}
return {
askQuestion,
loading,
error,
chatHistory,
clearChatHistory,
loadChatHistory
}
}

@@ -0,0 +1,80 @@
import { useState, useEffect } from 'react'
import { OAuthService } from '../services/oauth.js'
const oauthService = new OAuthService()
export function useAuth() {
const [user, setUser] = useState(null)
const [agent, setAgent] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
initAuth()
}, [])
const initAuth = async () => {
try {
const authResult = await oauthService.checkAuth()
if (authResult) {
setUser(authResult.user)
setAgent(authResult.agent)
// If we're on callback page and authentication succeeded, notify parent
if (window.location.pathname === '/oauth/callback') {
console.log('OAuth callback completed, notifying parent window')
// Get referrer or use stored return URL
const returnUrl = sessionStorage.getItem('oauth_return_url') ||
document.referrer ||
window.location.origin
sessionStorage.removeItem('oauth_return_url')
// Notify parent window if in iframe, otherwise redirect directly
if (window.parent !== window) {
window.parent.postMessage({
type: 'oauth_success',
returnUrl: returnUrl,
user: authResult.user
}, '*')
} else {
// Set flag to skip loading screen after redirect
sessionStorage.setItem('oauth_just_completed', 'true')
// Direct redirect
setTimeout(() => {
window.location.href = returnUrl
}, 1000)
}
}
}
} catch (error) {
console.error('Auth initialization failed:', error)
} finally {
setLoading(false)
}
}
const login = async (handle) => {
// Store current page URL for post-auth redirect
if (window.location.pathname !== '/oauth/callback') {
sessionStorage.setItem('oauth_return_url', window.location.href)
}
await oauthService.login(handle)
}
const logout = async () => {
await oauthService.logout()
setUser(null)
setAgent(null)
}
return {
user,
agent,
loading,
login,
logout,
isAuthenticated: !!user
}
}

@@ -1,7 +1,8 @@
import { useState, useEffect } from 'react'
import { atproto, collections } from '../api/atproto.js'
import { getApiConfig, isSyuIsHandle } from '../utils/pds.js'
import { getApiConfig, isSyuIsHandle, getPdsFromHandle } from '../utils/pds.js'
import { env } from '../config/env.js'
import { logger } from '../utils/logger.js'
export function useUserData(adminData) {
const [userComments, setUserComments] = useState([])
@@ -24,8 +25,53 @@ export function useUserData(adminData) {
env.collection
)
// 2. Get chat records (ai.syui.log.chat doesn't exist, so skip for now)
setChatRecords([])
// 2. Get chat records from ai.syui.log.chat and process into pairs
const chatResult = await collections.getChat(
adminData.apiConfig.pds,
adminData.did,
env.collection
)
const chatRecords = chatResult.records || chatResult
logger.log('useUserData: raw chatRecords:', chatRecords.length, chatRecords)
// Process chat records into question-answer pairs
const chatPairs = []
const recordMap = new Map()
// First pass: organize records by base rkey
chatRecords.forEach(record => {
const rkey = record.uri.split('/').pop()
const baseRkey = rkey.replace('-answer', '')
if (!recordMap.has(baseRkey)) {
recordMap.set(baseRkey, { question: null, answer: null })
}
if (record.value.type === 'question') {
recordMap.get(baseRkey).question = record
} else if (record.value.type === 'answer') {
recordMap.get(baseRkey).answer = record
}
})
// Second pass: create chat pairs
recordMap.forEach((pair, rkey) => {
if (pair.question) {
chatPairs.push({
rkey,
question: pair.question,
answer: pair.answer,
createdAt: pair.question.value.createdAt
})
}
})
// Sort by creation time (newest first)
chatPairs.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
logger.log('useUserData: processed chatPairs:', chatPairs.length, chatPairs)
setChatRecords(chatPairs)
// 3. Get base collection records which contain user comments
const baseRecords = await collections.getBase(
@@ -57,7 +103,7 @@ export function useUserData(adminData) {
// Also try to get individual user records from the user list
// Currently skipping user list processing since users contain placeholder DIDs
if (userListRecords.length > 0 && userListRecords[0].value?.users) {
console.log('User list found, but skipping placeholder users for now')
logger.log('User list found, but skipping placeholder users for now')
// Filter out placeholder users
const realUsers = userListRecords[0].value.users.filter(user =>
@@ -68,7 +114,7 @@ export function useUserData(adminData) {
)
if (realUsers.length > 0) {
console.log(`Processing ${realUsers.length} real users`)
logger.log(`Processing ${realUsers.length} real users`)
for (const user of realUsers) {
const userHandle = user.handle
@@ -83,14 +129,21 @@ export function useUserData(adminData) {
userPds = user.pds.replace('https://', '')
userApiConfig = getApiConfig(userPds)
} else {
// Auto-detect PDS based on handle and get real DID
if (isSyuIsHandle(userHandle)) {
// Always get actual PDS from describeRepo first
try {
// Try bsky.social first for most handles
const bskyPds = 'bsky.social'
userDid = await atproto.getDid(bskyPds, userHandle)
// Get the actual PDS endpoint from DID
const realPds = await getPdsFromHandle(userHandle)
userPds = realPds.replace('https://', '')
userApiConfig = getApiConfig(realPds)
} catch (error) {
// Fallback to syu.is if bsky.social fails
logger.warn(`Failed to get PDS for ${userHandle} from bsky.social, trying syu.is:`, error)
userPds = env.pds
userApiConfig = getApiConfig(userPds)
userDid = await atproto.getDid(userPds, userHandle)
} else {
userPds = 'bsky.social'
userApiConfig = getApiConfig(userPds)
userApiConfig = getApiConfig(env.pds)
userDid = await atproto.getDid(userPds, userHandle)
}
}
@@ -112,7 +165,7 @@ export function useUserData(adminData) {
try {
profile = await atproto.getProfile(userApiConfig.bsky, userDid)
} catch (profileError) {
console.warn(`Failed to get profile for ${userHandle}:`, profileError)
logger.warn(`Failed to get profile for ${userHandle}:`, profileError)
}
// Add profile info to each record
@@ -131,11 +184,11 @@ export function useUserData(adminData) {
allUserComments.push(...enrichedRecords)
} catch (userError) {
console.warn(`Failed to fetch data for user ${userHandle}:`, userError)
logger.warn(`Failed to fetch data for user ${userHandle}:`, userError)
}
}
} else {
console.log('No real users found in user list - all appear to be placeholders')
logger.log('No real users found in user list - all appear to be placeholders')
}
}

10
oauth/src/main.jsx Normal file

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './App.css'
// Only mount the OAuth app if the target element exists
const targetElement = document.getElementById('comment-atproto')
if (targetElement) {
ReactDOM.createRoot(targetElement).render(<App />)
}

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

@@ -0,0 +1,214 @@
/**
* AI Provider Abstract Interface
* Supports multiple AI backends (Ollama, Claude, etc.)
*/
export class AIProvider {
constructor(config) {
this.config = config
}
/**
* Send a question to the AI and get a response
* @param {string} question - User's question
* @param {Object} context - Additional context (user info, etc.)
* @returns {Promise<{answer: string}>}
*/
async ask(question, context = {}) {
throw new Error('ask() method must be implemented by subclass')
}
/**
* Check if the provider is available
* @returns {Promise<boolean>}
*/
async healthCheck() {
throw new Error('healthCheck() method must be implemented by subclass')
}
}
/**
* Ollama Provider Implementation
*/
export class OllamaProvider extends AIProvider {
constructor(config) {
super(config)
this.host = config.host || 'https://ollama.syui.ai'
this.model = config.model || 'gemma3:1b'
this.systemPrompt = config.systemPrompt || ''
}
async ask(question, context = {}) {
// Build enhanced prompt with user context
const userInfo = context.userProfile
? `相手の名前は${context.userProfile.displayName || context.userProfile.handle}です。`
: ''
const enhancedSystemPrompt = `${this.systemPrompt} ${userInfo}`
const prompt = `${enhancedSystemPrompt}
Question: ${question}
Answer:`
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 30000)
try {
const response = await fetch(`${this.host}/api/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Origin': 'https://syui.ai',
},
body: JSON.stringify({
model: this.model,
prompt: prompt,
stream: false,
options: {
temperature: 0.9,
top_p: 0.9,
num_predict: 200,
repeat_penalty: 1.1,
}
}),
signal: controller.signal
})
clearTimeout(timeoutId)
if (!response.ok) {
throw new Error(`Ollama API error: ${response.status}`)
}
const data = await response.json()
return { answer: data.response || 'エラーが発生しました' }
} catch (error) {
clearTimeout(timeoutId)
if (error.name === 'AbortError') {
throw new Error('Request timeout')
}
throw error
}
}
async healthCheck() {
try {
const response = await fetch(`${this.host}/api/tags`, {
method: 'GET',
headers: {
'Origin': 'https://syui.ai',
}
})
return response.ok
} catch {
return false
}
}
}
/**
* Claude MCP Server Provider Implementation
*/
export class ClaudeMCPProvider extends AIProvider {
constructor(config) {
super(config)
this.endpoint = config.endpoint || 'https://your-server.com/api/claude-mcp'
this.apiKey = config.apiKey // Server-side auth token
this.systemPrompt = config.systemPrompt || ''
}
async ask(question, context = {}) {
const userInfo = context.userProfile
? `相手の名前は${context.userProfile.displayName || context.userProfile.handle}です。`
: ''
const enhancedSystemPrompt = `${this.systemPrompt} ${userInfo}`
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 45000) // Longer timeout for Claude
try {
const response = await fetch(this.endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
},
body: JSON.stringify({
question: question,
systemPrompt: enhancedSystemPrompt,
context: context
}),
signal: controller.signal
})
clearTimeout(timeoutId)
if (!response.ok) {
throw new Error(`Claude MCP error: ${response.status}`)
}
const data = await response.json()
return { answer: data.answer || 'エラーが発生しました' }
} catch (error) {
clearTimeout(timeoutId)
if (error.name === 'AbortError') {
throw new Error('Request timeout')
}
throw error
}
}
async healthCheck() {
try {
const response = await fetch(`${this.endpoint}/health`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
}
})
return response.ok
} catch {
return false
}
}
}
/**
* AI Provider Factory
*/
export class AIProviderFactory {
static create(provider, config) {
switch (provider) {
case 'ollama':
return new OllamaProvider(config)
case 'claude-mcp':
return new ClaudeMCPProvider(config)
default:
throw new Error(`Unknown AI provider: ${provider}`)
}
}
static createFromEnv() {
const provider = import.meta.env.VITE_AI_PROVIDER || 'ollama'
const config = {
systemPrompt: import.meta.env.VITE_AI_SYSTEM_PROMPT || '',
}
switch (provider) {
case 'ollama':
config.host = import.meta.env.VITE_AI_HOST
config.model = import.meta.env.VITE_AI_MODEL
break
case 'claude-mcp':
config.endpoint = import.meta.env.VITE_CLAUDE_MCP_ENDPOINT
config.apiKey = import.meta.env.VITE_CLAUDE_MCP_API_KEY
break
}
return AIProviderFactory.create(provider, config)
}
}

@@ -1,105 +0,0 @@
import axios from 'axios';
import { CardDrawResult } from '../types/card';
// ai.card 直接APIアクセスメイン
const API_HOST = import.meta.env.VITE_API_HOST || '';
const API_BASE = import.meta.env.PROD && API_HOST ? `${API_HOST}/api/v1` : '/api/v1';
// ai.gpt MCP統合オプション機能
const AI_GPT_BASE = import.meta.env.VITE_ENABLE_AI_FEATURES === 'true'
? (import.meta.env.PROD ? '/api/ai-gpt' : 'http://localhost:8001')
: null;
const cardApi_internal = axios.create({
baseURL: API_BASE,
headers: {
'Content-Type': 'application/json',
},
});
const aiGptApi = AI_GPT_BASE ? axios.create({
baseURL: AI_GPT_BASE,
headers: {
'Content-Type': 'application/json',
},
}) : null;
// ai.cardの直接API基本機能
export const cardApi = {
drawCard: async (userDid: string, isPaid: boolean = false): Promise<CardDrawResult> => {
const response = await cardApi_internal.post('/cards/draw', {
user_did: userDid,
is_paid: isPaid,
});
return response.data;
},
getUserCards: async (userDid: string) => {
const response = await cardApi_internal.get(`/cards/user/${userDid}`);
return response.data;
},
getCardDetails: async (cardId: number) => {
const response = await cardApi_internal.get(`/cards/${cardId}`);
return response.data;
},
getUniqueCards: async () => {
const response = await cardApi_internal.get('/cards/unique');
return response.data;
},
getGachaStats: async () => {
const response = await cardApi_internal.get('/cards/stats');
return response.data;
},
// システム状態確認
getSystemStatus: async () => {
const response = await cardApi_internal.get('/health');
return response.data;
},
};
// ai.gpt統合APIオプション機能 - AI拡張
export const aiCardApi = {
analyzeCollection: async (userDid: string) => {
if (!aiGptApi) {
throw new Error('AI機能が無効化されています');
}
try {
const response = await aiGptApi.get('/card_analyze_collection', {
params: { did: userDid }
});
return response.data.data;
} catch (error) {
throw new Error('AI分析機能を利用するにはai.gptサーバーが必要です');
}
},
getEnhancedStats: async () => {
if (!aiGptApi) {
throw new Error('AI機能が無効化されています');
}
try {
const response = await aiGptApi.get('/card_get_gacha_stats');
return response.data.data;
} catch (error) {
throw new Error('AI統計機能を利用するにはai.gptサーバーが必要です');
}
},
// AI機能が利用可能かチェック
isAIAvailable: async (): Promise<boolean> => {
if (!aiGptApi || import.meta.env.VITE_ENABLE_AI_FEATURES !== 'true') {
return false;
}
try {
await aiGptApi.get('/health');
return true;
} catch (error) {
return false;
}
},
};

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