Compare commits
89 Commits
test-oauth
...
main
Author | SHA1 | Date | |
---|---|---|---|
f1e76ab31f
|
|||
3c9ef78696
|
|||
ee2d21b0f3
|
|||
0667ac58fb
|
|||
d89855338b
|
|||
e19170cdff
|
|||
c3e22611f5
|
|||
2943c94ec1
|
|||
f27997b7e8
|
|||
447e4bded9
|
|||
03161a52ca
|
|||
fe9381a860
|
|||
f0cea89005
|
|||
b059fe1de0
|
|||
07b0b0f702
|
|||
ecd69557fe
|
|||
452a0fda6a
|
|||
a62dd82790
|
|||
3faec33bac
|
|||
33402f4a21
|
|||
3e65bc8210
|
|||
16d724ec25
|
|||
69182a1bf8
|
|||
0110773592
|
|||
75f108e7b8
|
|||
263189ce72
|
|||
7800a655f3
|
|||
76c797e4d8
|
|||
d1a1c92842
|
|||
9da1f87640
|
|||
ddfc43512c
|
|||
b3ccd61935
|
|||
a243b6a44e
|
|||
e3c1cf4790
|
|||
a6236661bf
|
|||
195a4474c9
|
|||
4a34a6ca59
|
|||
4d01fb8507
|
|||
d69c9aa09b
|
|||
99ee49f76e
|
|||
19c0e28668
|
|||
bc99eb0814
|
|||
cf93721bad
|
|||
8a8a121f4a
|
|||
be2bcae1d6
|
|||
2c08a4acfb
|
|||
7791399314
|
|||
26b1b2cf87
|
|||
7eb653f569
|
|||
0fc920c844
|
|||
13c05d97d2
|
|||
71acd44810
|
|||
1b4579d0f1
|
|||
09100f6d99
|
|||
169de9064a
|
|||
097c794623
|
|||
b652e01dd3
|
|||
31af524303
|
|||
6be024864d
|
|||
eef1fdad38
|
|||
b7e411e8b2
|
|||
8f9d803a94
|
|||
f9b9c2ab52
|
|||
210ce801f1
|
|||
6cb46f2ca1
|
|||
9406597b82
|
|||
0dbc3ba67e
|
|||
a7e6fc4a1a
|
|||
3adcfdacf5
|
|||
004081337c
|
|||
5ce0e0fd7a
|
|||
f816abb84f
|
|||
8541af9293
|
|||
68b49d5aaf
|
|||
53dab3fd09
|
|||
5fac689f98
|
|||
293421b7a5
|
|||
1793de40c1
|
|||
30bdd7b633
|
|||
b17ac3d91a
|
|||
81f87d0462
|
|||
a020fa24d8
|
|||
21c53010b7
|
|||
4f7834f85c
|
|||
fecd927b91
|
|||
b54e8089ea
|
|||
174cb12d4d
|
|||
a1186f8185
|
|||
833549756b
|
.claude
.gitea/workflows
.github/workflows
.gitignoreCargo.tomlbin
my-blog
config.toml
content
posts
layouts
shortcodes
oauth
static
css
img
aios_v0201.pngatproto_oauth_syuis.pngbluecheck_ozone_socialapp.pngue_blender_model_ai_v0401.pngue_blender_model_ai_v0402.pngue_blender_model_ai_v0501.pngue_blender_model_ai_v0502.pngue_blender_model_ai_v0601.pngue_blender_model_ai_v0602.pngue_blender_model_ai_v0603.pngue_blender_model_ai_v0604.png
js
oauth
templates
oauth
.env.env.productionbuild-minimal.jsindex.html
json
ai.syui.ai_chat_comment.jsonai.syui.ai_chat_lang.jsonai.syui.ai_log.jsonai.syui.ai_user.jsonsyui.syui.ai_chat.jsonsyui.syui.ai_log.json
package.jsonpublic
src
App.cssApp.jsxApp.tsx
tsconfig.jsontsconfig.node.jsonvite.config.jsvite.config.tsapi
components
AIChat-access.tsxAIChat.tsxAIProfile.tsxAskAI.jsxAuthButton.jsxAvatar.jsxAvatarImage.jsxAvatarTest.jsxAvatarTestPanel.jsxCard.tsxCardBox.tsxCardList.tsxChatRecordList.jsxCollectionAnalysis.tsxCommentForm.jsxGachaAnimation.tsxGachaStats.tsxLoadingSkeleton.jsxLogin.tsxOAuthCallback.jsxOAuthCallback.tsxOAuthCallbackPage.tsxProfileForm.jsxProfileRecordList.jsxRecordList.jsxRecordTabs.jsxTestUI.jsxUserLookup.jsx
config
hooks
main.jsxmain.tsxservices
styles
tests
types
utils
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": []
|
||||
}
|
||||
}
|
123
.gitea/workflows/cloudflare-pages.yml
Normal file
123
.gitea/workflows/cloudflare-pages.yml
Normal file
@@ -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 }} } } }"
|
193
.gitea/workflows/release.yml
Normal file
193
.gitea/workflows/release.yml
Normal file
@@ -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
|
76
.github/workflows/cloudflare-pages.yml
vendored
76
.github/workflows/cloudflare-pages.yml
vendored
@@ -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
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"]
|
||||
|
||||
|
78
my-blog/content/posts/2025-06-19-oauth.md
Normal file
78
my-blog/content/posts/2025-06-19-oauth.md
Normal file
@@ -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`ともに動くようにしました。
|
||||
|
||||

|
||||
|
||||
ここでいう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',
|
||||
});
|
||||
```
|
||||
|
40
my-blog/content/posts/2025-06-30-ue.md
Normal file
40
my-blog/content/posts/2025-06-30-ue.md
Normal file
@@ -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>
|
80
my-blog/content/posts/2025-07-05-aios.md
Normal file
80
my-blog/content/posts/2025-07-05-aios.md
Normal file
@@ -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を作り始めました。
|
||||
|
||||

|
||||
|
||||
名前は`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"
|
||||
```
|
||||
|
114
my-blog/content/posts/2025-07-11-blender.md
Normal file
114
my-blog/content/posts/2025-07-11-blender.md
Normal file
@@ -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を分離して色を付けています。
|
||||
|
||||

|
||||
|
||||
## 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>
|
||||
|
||||

|
||||
|
||||
キャラクターが動く場合は、`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を指定すると楽でしょう。
|
||||
|
||||

|
||||
|
||||
## 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>
|
||||
|
36
my-blog/content/posts/2025-07-11-blender2.md
Normal file
36
my-blog/content/posts/2025-07-11-blender2.md
Normal file
@@ -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のバランスが悪かったので調整しました。
|
||||
|
||||
具体的には、髪の毛を少し下げました。
|
||||
|
155
my-blog/content/posts/2025-07-12-game.md
Normal file
155
my-blog/content/posts/2025-07-12-game.md
Normal file
@@ -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が上がっていくでしょう。
|
||||
|
48
my-blog/content/posts/2025-07-14-firefox.md
Normal file
48
my-blog/content/posts/2025-07-14-firefox.md
Normal file
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
10
my-blog/content/posts/2025-07-16-6bf4b020.md
Normal file
10
my-blog/content/posts/2025-07-16-6bf4b020.md
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
title: "ゲームとAI制御"
|
||||
slug: "6bf4b020"
|
||||
date: "2025-07-16"
|
||||
tags: ["ai", "conversation"]
|
||||
draft: false
|
||||
extra:
|
||||
type: "ai"
|
||||
---
|
||||
|
40
my-blog/content/posts/2025-07-16-ailog.md
Normal file
40
my-blog/content/posts/2025-07-16-ailog.md
Normal file
@@ -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)
|
14
my-blog/layouts/shortcodes/msg.html
Normal file
14
my-blog/layouts/shortcodes/msg.html
Normal file
@@ -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
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
BIN
my-blog/static/img/aios_v0201.png
Normal file
BIN
my-blog/static/img/aios_v0201.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 263 KiB |
BIN
my-blog/static/img/atproto_oauth_syuis.png
Normal file
BIN
my-blog/static/img/atproto_oauth_syuis.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 256 KiB |
BIN
my-blog/static/img/bluecheck_ozone_socialapp.png
Normal file
BIN
my-blog/static/img/bluecheck_ozone_socialapp.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 49 KiB |
BIN
my-blog/static/img/ue_blender_model_ai_v0401.png
Normal file
BIN
my-blog/static/img/ue_blender_model_ai_v0401.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 1.5 MiB |
BIN
my-blog/static/img/ue_blender_model_ai_v0402.png
Normal file
BIN
my-blog/static/img/ue_blender_model_ai_v0402.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 1.5 MiB |
BIN
my-blog/static/img/ue_blender_model_ai_v0501.png
Normal file
BIN
my-blog/static/img/ue_blender_model_ai_v0501.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 1.6 MiB |
BIN
my-blog/static/img/ue_blender_model_ai_v0502.png
Normal file
BIN
my-blog/static/img/ue_blender_model_ai_v0502.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 1.6 MiB |
BIN
my-blog/static/img/ue_blender_model_ai_v0601.png
Normal file
BIN
my-blog/static/img/ue_blender_model_ai_v0601.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 1.9 MiB |
BIN
my-blog/static/img/ue_blender_model_ai_v0602.png
Normal file
BIN
my-blog/static/img/ue_blender_model_ai_v0602.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 2.0 MiB |
BIN
my-blog/static/img/ue_blender_model_ai_v0603.png
Normal file
BIN
my-blog/static/img/ue_blender_model_ai_v0603.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 2.0 MiB |
BIN
my-blog/static/img/ue_blender_model_ai_v0604.png
Normal file
BIN
my-blog/static/img/ue_blender_model_ai_v0604.png
Normal file
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
|
||||
|
123
my-blog/static/js/image-comparison.js
Normal file
123
my-blog/static/js/image-comparison.js
Normal file
@@ -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
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();
|
||||
});
|
||||
});
|
||||
|
3
my-blog/static/oauth/index.html
Normal file
3
my-blog/static/oauth/index.html
Normal file
@@ -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">
|
61
my-blog/templates/at-browser-assets.html
Normal file
61
my-blog/templates/at-browser-assets.html
Normal file
@@ -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
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 %}
|
48
my-blog/templates/pds-header.html
Normal file
48
my-blog/templates/pds-header.html
Normal file
@@ -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()">×</button>
|
||||
<div id="atUriContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
6
my-blog/templates/pds.html
Normal file
6
my-blog/templates/pds.html
Normal file
@@ -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
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>
|
62
oauth/json/ai.syui.ai_chat_comment.json
Normal file
62
oauth/json/ai.syui.ai_chat_comment.json
Normal file
@@ -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"
|
||||
}
|
62
oauth/json/ai.syui.ai_chat_lang.json
Normal file
62
oauth/json/ai.syui.ai_chat_lang.json
Normal file
@@ -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! I’m Ai, and I made a super cool blog! It’s called “ailog,” and it’s 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.” It’s 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`. It’s 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! It’s 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! It’s like having a really, really good friend who knows everything! (But it's still changing, so maybe it won’t 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 (Don’t Worry, I’ll 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! Isn’t that amazing?!\n\n**Testing, Testing, 1, 2, 3!**\n\nI need to test my blog to make sure everything works perfectly. I’ll 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 Ai’s 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, here’s the translation of the blog post, written as if it’s 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 – it’s like a time capsule! But now it’s on Cloudflare Pages! It’s super shiny! \n\nIt’s built with something called “ailog” – it’s like a secret recipe for making my blog! \n\n**Here’s how it works (it’s kinda magic!)**\n\n1. **Checking Out:** It’s like, “Hey, let’s look at all the files!” (This is the `actions/checkout@v4` part – it’s like a super-fast peek!)\n\n2. **Rust Time!** It needs Rust – it’s 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. It’s 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?! I’m so good at computers! I even know about tiny things, like…uh…well, never mind! 😉 It’s super cool! 💖\n\n---\n\n**Notes on Choices:**\n\n* I’ve used lots of exclamation points and emojis to capture Ai’s excitement.\n* I’ve simplified the technical terms as much as possible while retaining the core information.\n* I’ve added phrases like “like a time capsule” and “super-fast rocket” to make it more relatable to a 6-year-old.\n* I’ve kept the code blocks as they are, as they’re 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"
|
||||
}
|
30
oauth/json/ai.syui.ai_log.json
Normal file
30
oauth/json/ai.syui.ai_log.json
Normal file
@@ -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"
|
||||
}
|
53
oauth/json/ai.syui.ai_user.json
Normal file
53
oauth/json/ai.syui.ai_user.json
Normal file
@@ -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"
|
||||
}
|
53
oauth/json/syui.syui.ai_chat.json
Normal file
53
oauth/json/syui.syui.ai_chat.json
Normal file
@@ -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"
|
||||
}
|
3
oauth/json/syui.syui.ai_log.json
Normal file
3
oauth/json/syui.syui.ai_log.json
Normal file
@@ -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
|
||||
}
|
2165
oauth/src/App.css
2165
oauth/src/App.css
File diff suppressed because it is too large
Load Diff
493
oauth/src/App.jsx
Normal file
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>
|
||||
)
|
||||
}
|
1631
oauth/src/App.tsx
1631
oauth/src/App.tsx
File diff suppressed because it is too large
Load Diff
221
oauth/src/api/atproto.js
Normal file
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>
|
||||
);
|
||||
};
|
399
oauth/src/components/AskAI.jsx
Normal file
399
oauth/src/components/AskAI.jsx
Normal file
@@ -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>
|
||||
)
|
||||
}
|
77
oauth/src/components/AuthButton.jsx
Normal file
77
oauth/src/components/AuthButton.jsx
Normal file
@@ -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>
|
||||
)
|
||||
}
|
234
oauth/src/components/Avatar.jsx
Normal file
234
oauth/src/components/Avatar.jsx
Normal file
@@ -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>
|
||||
)
|
||||
}
|
103
oauth/src/components/AvatarImage.jsx
Normal file
103
oauth/src/components/AvatarImage.jsx
Normal file
@@ -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>
|
||||
)
|
||||
}
|
203
oauth/src/components/AvatarTest.jsx
Normal file
203
oauth/src/components/AvatarTest.jsx
Normal file
@@ -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>
|
||||
)
|
||||
}
|
246
oauth/src/components/AvatarTestPanel.jsx
Normal file
246
oauth/src/components/AvatarTestPanel.jsx
Normal file
@@ -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>
|
||||
);
|
||||
};
|
231
oauth/src/components/ChatRecordList.jsx
Normal file
231
oauth/src/components/ChatRecordList.jsx
Normal file
@@ -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>
|
||||
);
|
||||
};
|
134
oauth/src/components/CommentForm.jsx
Normal file
134
oauth/src/components/CommentForm.jsx
Normal file
@@ -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>
|
||||
);
|
||||
};
|
98
oauth/src/components/LoadingSkeleton.jsx
Normal file
98
oauth/src/components/LoadingSkeleton.jsx
Normal file
@@ -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>
|
||||
);
|
||||
};
|
36
oauth/src/components/OAuthCallback.jsx
Normal file
36
oauth/src/components/OAuthCallback.jsx
Normal file
@@ -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>
|
||||
);
|
||||
};
|
165
oauth/src/components/ProfileForm.jsx
Normal file
165
oauth/src/components/ProfileForm.jsx
Normal file
@@ -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
|
136
oauth/src/components/ProfileRecordList.jsx
Normal file
136
oauth/src/components/ProfileRecordList.jsx
Normal file
@@ -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>
|
||||
)
|
||||
}
|
154
oauth/src/components/RecordList.jsx
Normal file
154
oauth/src/components/RecordList.jsx
Normal file
@@ -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>
|
||||
)
|
||||
}
|
283
oauth/src/components/RecordTabs.jsx
Normal file
283
oauth/src/components/RecordTabs.jsx
Normal file
@@ -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>
|
||||
)
|
||||
}
|
531
oauth/src/components/TestUI.jsx
Normal file
531
oauth/src/components/TestUI.jsx
Normal file
@@ -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不要のテスト用UI。accessJwtと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();
|
195
oauth/src/hooks/useAdminData.js
Normal file
195
oauth/src/hooks/useAdminData.js
Normal file
@@ -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
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
|
||||
}
|
||||
}
|
80
oauth/src/hooks/useAuth.js
Normal file
80
oauth/src/hooks/useAuth.js
Normal file
@@ -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
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>,
|
||||
);
|
||||
});
|
214
oauth/src/services/aiProvider.js
Normal file
214
oauth/src/services/aiProvider.js
Normal file
@@ -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
Reference in New Issue
Block a user