Compare commits
115 Commits
v0.2.2
...
2a48c43721
Author | SHA1 | Date | |
---|---|---|---|
2a48c43721
|
|||
61fd950127
|
|||
f9c0e6e872
|
|||
63d1d11843
|
|||
846dbec5c5
|
|||
adc1739a0c
|
|||
07376b889d
|
|||
efa24f0c0a
|
|||
e24e736a8f
|
|||
8738f24ff5
|
|||
de2bf03762
|
|||
7c020d3081
|
|||
49bf609eda
|
|||
3e8f3b372e
|
|||
f117aa2cbd
|
|||
1aa1d86a83
|
|||
57381c88e7
|
|||
be22e30697
|
|||
7a87fb9795
|
|||
a6da73f772
|
|||
976107903b
|
|||
2aa6616209
|
|||
a2ec41bf87
|
|||
3af175f4ed
|
|||
0e1dff12c2
|
|||
5708e9c53d
|
|||
824aee7b74
|
|||
aff1b3a7bd
|
|||
fa524138c6
|
|||
6047fa4161
|
|||
c5a70a433b
|
|||
0d90ba21e0
|
|||
e1eab122c8
|
|||
c3cb3db680
|
|||
55745ff051
|
|||
5aeeba106a
|
|||
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
|
@@ -1,60 +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:*)",
|
||||
"Bash(./scpt/run.zsh:*)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
}
|
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
@@ -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
|
26
.github/workflows/cloudflare-pages.yml
vendored
@@ -7,7 +7,7 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
OAUTH_DIR: oauth_new
|
||||
OAUTH_DIR: oauth
|
||||
KEEP_DEPLOYMENTS: 5
|
||||
|
||||
jobs:
|
||||
@@ -41,13 +41,24 @@ jobs:
|
||||
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
|
||||
with:
|
||||
path: ./bin
|
||||
key: ailog-bin-${{ runner.os }}
|
||||
key: ailog-bin-${{ runner.os }}-v${{ hashFiles('Cargo.toml') }}
|
||||
restore-keys: |
|
||||
ailog-bin-${{ runner.os }}
|
||||
ailog-bin-${{ runner.os }}-v
|
||||
|
||||
- name: Setup ailog binary
|
||||
run: |
|
||||
@@ -114,13 +125,14 @@ jobs:
|
||||
needs: deploy
|
||||
runs-on: ubuntu-latest
|
||||
if: success()
|
||||
|
||||
steps:
|
||||
- name: Wait for deployment to complete
|
||||
run: sleep 3
|
||||
|
||||
- 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" \
|
||||
|
1
.github/workflows/release.yml
vendored
@@ -48,6 +48,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
|
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.2"
|
||||
version = "0.3.4"
|
||||
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"
|
||||
|
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
64
my-blog/content/posts/2025-07-30-game.md
Normal file
@@ -0,0 +1,64 @@
|
||||
---
|
||||
title: "ue5のgaspとdragonikを組み合わせてenemyを作る"
|
||||
slug: "gasp-dragonik-enemy-chbcharacter"
|
||||
date: "2025-07-30"
|
||||
tags: ["ue"]
|
||||
draft: false
|
||||
---
|
||||
|
||||
ue5.6でgasp(game animation sample project)をベースにゲーム、特にキャラクターの操作を作っています。
|
||||
|
||||
そして、enemy(敵)を作り、バトルシーンを作成する予定ですが、これはどのように開発すればいいのでしょう。その方針を明確にします。
|
||||
|
||||
1. enemyもgaspの`cbp_character`に統合し、自キャラ、敵キャラどちらでも使用可能にする
|
||||
2. 2番目のcharacterは動物型(type:animal)にし、gaspに統合する
|
||||
3. enemyとして使用する場合は、enemy-AI-componentを追加するだけで完結する
|
||||
4. characterのすべての操作を統一する
|
||||
|
||||
このようにすることで、応用可能なenemyを作ることができます。
|
||||
|
||||
例えば、`2番目のcharacterは動物型(type:animal)にする`というのはどういうことでしょう。
|
||||
|
||||
登場するキャラクターを人型(type:human), 動物型(type:animal)に分けるとして、動物型のテンプレートを作る必要があります。そのまま動物のmeshをgaspで使うと動きが変になってしまうので、それを調整する必要があるということ。そして、調整したものをテンプレート化して、他の動物にも適用できるようにしておくと、後の開発は楽ですよね。
|
||||
|
||||
ですから、早いうちにtype:humanから脱却し、他のtypeを作るほうがいいと判断しました。
|
||||
|
||||
これには、`dragon ik plugin`を使って、手っ取り早く動きを作ります。
|
||||
|
||||
`characterのすべての操作を統一する`というのは、1キャラにつき1属性、1通常攻撃、1スキル、1バースト、などのルールを作り、それらを共通化することです。共通化というのは、playerもenemy-AI-componentも違うキャラを同じ操作で使用できることを指します。
|
||||
|
||||
## 2番目のキャラクター
|
||||
|
||||
原作には、西洋ドラゴンのドライ(drai)というキャラが登場します。その父親が東洋ドラゴンのシンオウ(shin-oh)です。これをshinという名前で登録し、2番目のキャラクターとして設定しました。
|
||||
|
||||
3d-modelは今のところue5のcrsp(control rig sample project)にあるchinese dragonを使用しています。後に改造して原作に近づけるようにしたいところですが、今は時間が取れません。
|
||||
|
||||
<iframe width="100%" height="415" src="https://www.youtube.com/embed/3c3Q1Z5r7QI" 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>
|
||||
|
||||
## データ構造の作成と適用
|
||||
|
||||
ゲームデータはatproto collection recordに保存して、そこからゲームに反映させたいと考えています。
|
||||
|
||||
まず基本データを`ai.syui.ai`のアカウントに保存。個別データをplayerのatprotoアカウントに保存する形が理想です。
|
||||
|
||||
基本データは、ゲームの基本的な設定のこと。例えば、キャラクターの名前や属性、スキルなど変更されることがない値。
|
||||
|
||||
個別データは、プレイヤーが使えるキャラ、レベル、攻撃力など、ゲームの進行とともに変更される値です。
|
||||
|
||||
ゲームをスタートさせると、まず基本データを取得し、それを`cbp_character`に適用します。ログインすると、`cbp_character`の変数(var)に値が振り分けられます。例えば、`skill-damage:0.0`があったとして、この値が変わります。
|
||||
|
||||
しかし、ゲームを開発していると、基本データも個別データも構造が複雑になります。
|
||||
|
||||
それを防ぐため、`{simple, core} mode`のような考え方を取り入れます。必要最小限の構成を分離、保存して、それをいつでも統合、適用できるように設計しておきます。
|
||||
|
||||
## gaspとdragonikを統合する方法
|
||||
|
||||
では、いよいよgaspとdragonikの統合手法を解説します。
|
||||
|
||||
まず、abpを作ります。それにdragonikを当て、それをSKM_Dragonのpost process animに指定します。
|
||||
|
||||

|
||||
|
||||
次に、動きに合わせて首を上下させます。
|
||||
|
||||
<iframe src="https://blueprintue.com/render/piiw14oz" scrolling="no" allowfullscreen style="width:100%;height:400px"></iframe>
|
350
my-blog/content/posts/2025-08-08-arch.md
Normal file
@@ -0,0 +1,350 @@
|
||||
---
|
||||
title: "archlinux install by syui"
|
||||
slug: "arch"
|
||||
date: "2025-08-08"
|
||||
tags: ["arch"]
|
||||
draft: false
|
||||
---
|
||||
|
||||
## 最小構成
|
||||
|
||||
まずはdiskの設定から。
|
||||
|
||||
```sh
|
||||
# cfdisk /dev/sda
|
||||
```
|
||||
|
||||
次にdiskのフォーマットなど。それをmountしてarchlinuxを入れます。bootloaderも設定しておきましょう。
|
||||
|
||||
```sh
|
||||
$ mkfs.vfat /dev/sda1
|
||||
$ mkfs.ext4 /dev/sda2
|
||||
|
||||
$ mount /dev/sda2 /mnt
|
||||
$ mount --mkdir /dev/sda1 /mnt/boot
|
||||
|
||||
$ pacstrap /mnt base base-devel linux linux-firmware linux-headers
|
||||
$ genfstab -U /mnt >> /mnt/etc/fstab
|
||||
|
||||
$ arch-chroot /mnt
|
||||
$ pacman -S dhcpcd grub os-prober efibootmgr
|
||||
$ grub-install --target=x86_64-efi --efi-directory=/boot --bootloader-id=grub
|
||||
$ grub-mkconfig -o /boot/grub/grub.cfg
|
||||
```
|
||||
|
||||
これで`exit;reboot`すると起動できます。
|
||||
|
||||
## よく使うもの
|
||||
|
||||
```sh
|
||||
$ pacman -S openssh zsh vim git tmux cargo
|
||||
```
|
||||
|
||||
## userの作成
|
||||
|
||||
```sh
|
||||
$ passwd
|
||||
$ useradd -m -G wheel ${USER}
|
||||
$ passwd ${USER}
|
||||
```
|
||||
|
||||
```sh
|
||||
$ HOSTNAME=archlinux
|
||||
$ echo "$HOSTNAME" > /etc/hostname
|
||||
```
|
||||
|
||||
shellの変更など。
|
||||
|
||||
```sh
|
||||
$ chsh -s /bin/zsh ${USER}
|
||||
or
|
||||
$ useradd -m -G wheel -s /bin/zsh ${USER}
|
||||
```
|
||||
|
||||
## sudoの使い方
|
||||
|
||||
1. `/etc/sudoers`は編集を間違えると起動できなくなります。安全のため`visudo`が推奨されています。
|
||||
2. `vim`では`:w!`で保存します。
|
||||
|
||||
```sh
|
||||
$ sudo visudo
|
||||
or
|
||||
$ vim /etc/sudoers
|
||||
```
|
||||
|
||||
```sh:/etc/sudoers
|
||||
%wheel ALL=(ALL:ALL) ALL
|
||||
```
|
||||
|
||||
よく`update`する人は特定のコマンドをpasswordなしで実行できるようにしておいたほうが良いでしょう。
|
||||
|
||||
```sh:/etc/sudoers
|
||||
%wheel ALL=(ALL:ALL) NOPASSWD: /usr/bin/pacman -Syu --noconfirm
|
||||
```
|
||||
|
||||
```sh
|
||||
$ sudo pacman -Syu --noconfirm
|
||||
```
|
||||
|
||||
## networkの設定
|
||||
|
||||
次にnetworkです。ここでは`systemd-networkd`を使用します。`dhcpcd`を使ったほうが簡単ではあります。もし安定しないようなら`dhcpcd`を使用。
|
||||
|
||||
```sh
|
||||
# systemctl enable dhcpcd
|
||||
```
|
||||
|
||||
```sh
|
||||
$ systemctl enable systemd-networkd
|
||||
```
|
||||
|
||||
network deviceをeth0にします。
|
||||
|
||||
```sh
|
||||
$ ip link
|
||||
$ ln -s /dev/null /etc/udev/rules.d/80-net-setup-link.rules
|
||||
```
|
||||
|
||||
```sh:/etc/systemd/network/eth.network
|
||||
[Match]
|
||||
Name=eth0
|
||||
[Network]
|
||||
Address=192.168.1.2/24
|
||||
Gateway=192.168.1.1
|
||||
DNS=192.168.1.1
|
||||
```
|
||||
|
||||
```sh
|
||||
$ systemctl enable systemd-resolved
|
||||
```
|
||||
|
||||
## auto-login
|
||||
|
||||
次にauto-loginを設定していきます。ここでは`getty`を使用。`${USER}`のところを自分のusernameにしてください。
|
||||
|
||||
```sh
|
||||
$ mkdir -p /etc/systemd/system/getty@tty1.service.d/
|
||||
```
|
||||
|
||||
```sh:/etc/systemd/system/getty@tty1.service.d/override.conf
|
||||
[Service]
|
||||
ExecStart=
|
||||
ExecStart=-/usr/bin/agetty --autologin ${USER} --noclear %I $TERM
|
||||
```
|
||||
|
||||
```sh
|
||||
$ systemctl daemon-reload
|
||||
$ systemctl restart getty@tty1
|
||||
```
|
||||
|
||||
## window-manager
|
||||
|
||||
`xorg`でdesktop(window-manager)を作ります。`i3`を使うことにしましょう。`xorg`は`wayland`に乗り換えたほうがいいかも。その場合は`sway`がおすすめ。
|
||||
|
||||
```sh
|
||||
$ pacman -S xorg xorg-xinit i3 xterm
|
||||
|
||||
# 確認
|
||||
$ startx
|
||||
$ i3
|
||||
```
|
||||
|
||||
```sh:~/.xinitrc
|
||||
exec i3
|
||||
```
|
||||
|
||||
```sh:~/.bash_profile
|
||||
if [[ ! $DISPLAY && $XDG_VTNR -eq 1 ]]; then
|
||||
exec startx
|
||||
fi
|
||||
```
|
||||
|
||||
## sshの使い方
|
||||
|
||||
```sh
|
||||
$ systemctl enable sshd
|
||||
$ cat /etc/ssh/sshd_config
|
||||
Port 22119
|
||||
PasswordAuthentication no
|
||||
|
||||
$ systemctl restart sshd
|
||||
```
|
||||
|
||||
基本的にlanから使う場合はdefaultで問題ありませんが、wanから使う場合は変更します。とはいえ、lanでもport, passwordは変えておいたほうがいいでしょう。
|
||||
|
||||
次に接続側でkeyを作ってserverに登録します。
|
||||
|
||||
```sh
|
||||
$ ssh-keygen -f ~/.ssh/archlinux
|
||||
$ ssh-copy-id -i ~/.ssh/archlinux ${USER}@192.168.1.2 -p 22119
|
||||
```
|
||||
|
||||
`ssh-copy-id`がない場合は以下のようにしましょう。
|
||||
|
||||
```sh
|
||||
$ cat ~/.ssh/archlinux.pub | ssh -p 22119 ${USER}@192.168.1.2 'cat >> ~/.ssh/authorized_keys'
|
||||
```
|
||||
|
||||
この設定で`ssh archlinux`コマンドで接続できます。
|
||||
|
||||
```sh:~/.ssh/config
|
||||
Host archlinux
|
||||
User syui
|
||||
Hostname 192.168.1.2
|
||||
Port 22119
|
||||
IdentityFile ~/.ssh/archlinux
|
||||
```
|
||||
|
||||
おそらく、これがarchlinuxを普通に使っていくうえでの最小構成かと思います。
|
||||
|
||||
serverだけならxorgなどは必要ありません。
|
||||
|
||||
## zshの使い方
|
||||
|
||||
```sh
|
||||
$ sudo pacman -S git-zsh-completion powerline zsh-autocomplete zsh-autosuggestions zsh-completions zsh-history-substring-search zsh-syntax-highlighting
|
||||
```
|
||||
|
||||
例えば、`ls -`と入力すると補完され、`C-n`, `C-p`で選択。
|
||||
|
||||
```sh:~/.zshrc
|
||||
alias u="sudo pacman -Syu --noconfirm"
|
||||
alias zs="vim ~/.zshrc"
|
||||
alias zr="exec $SHELL && source ~/.zshrc"
|
||||
|
||||
source /usr/share/zsh/plugins/zsh-autocomplete/zsh-autocomplete.plugin.zsh
|
||||
source /usr/share/zsh/plugins/zsh-autosuggestions/zsh-autosuggestions.zsh
|
||||
source /usr/share/zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh
|
||||
source /usr/share/zsh/plugins/zsh-history-substring-search/zsh-history-substring-search.zsh
|
||||
# source /usr/share/powerline/bindings/zsh/powerline.zsh
|
||||
|
||||
autoload -Uz compinit
|
||||
compinit
|
||||
fpath=(/usr/share/zsh/site-functions $fpath)
|
||||
|
||||
HISTSIZE=10000
|
||||
SAVEHIST=10000
|
||||
HISTFILE=~/.zsh_history
|
||||
setopt SHARE_HISTORY
|
||||
setopt HIST_IGNORE_DUPS
|
||||
bindkey '^[[A' history-substring-search-up
|
||||
bindkey '^[[B' history-substring-search-down
|
||||
```
|
||||
|
||||
`powerline`は重いのでコメントしています。
|
||||
|
||||
## フリーズの解消
|
||||
|
||||
古いpcにlinuxを入れる際は`linux-fm`に注意してください。
|
||||
|
||||
頻繁にフリーズするようなら`linux-firmware`を削除するのがおすすめです。
|
||||
|
||||
```sh
|
||||
$ pacman -Q | grep linux-firmware
|
||||
$ pacman -R linux-firmware ...
|
||||
# pacman -S broadcom-wl-dkms
|
||||
```
|
||||
|
||||
## pacmanが壊れたときの対処法
|
||||
|
||||
```sh
|
||||
$ pacman -Syu
|
||||
# これがうまくいかないことがある
|
||||
```
|
||||
|
||||
```sh
|
||||
# dbがlockされている
|
||||
$ rm /var/lib/pacman/db.lock
|
||||
|
||||
# ファイルが存在すると言われる
|
||||
$ pacman -Qqn | pacman -S --overwrite "*" -
|
||||
|
||||
# pgp-keyをreinstallする
|
||||
$ pacman -S archlinux-keyring
|
||||
$ pacman-key --refresh-key
|
||||
```
|
||||
|
||||
## archlinuxの作り方
|
||||
|
||||
archlinuxはシンプルなshell scriptと言えるでしょう。なので色々と便利です。ここでは、`img.sh`, `install.sh`を作ります。
|
||||
|
||||
### img.sh
|
||||
|
||||
ここでは`archlinux.iso`, `archlinux.tar.gz`を生成します。これはarchlinux上で実行してください。
|
||||
|
||||
```sh:img.sh
|
||||
#!/bin/bash
|
||||
pacman -Syuu --noconfirm git base-devel archiso
|
||||
git clone https://gitlab.archlinux.org/archlinux/archiso
|
||||
./archiso/archiso/mkarchiso -v -o ./ ./archiso/configs/releng/
|
||||
mkdir -p work/x86_64/airootfs/var/lib/machines/arch
|
||||
pacstrap -c work/x86_64/airootfs/var/lib/machines/arch base
|
||||
arch-chroot work/x86_64/airootfs/ /bin/sh -c 'pacman-key --init'
|
||||
arch-chroot work/x86_64/airootfs/ /bin/sh -c 'pacman-key --populate archlinux'
|
||||
tar -zcvf archlinux.tar.gz -C work/x86_64/airootfs/ .
|
||||
```
|
||||
|
||||
例えば、`pacstrap`で自分の好きなツールを指定すれば、独自のimgを作成でき、`docker`にも使えます。
|
||||
|
||||
```sh
|
||||
$ docker import archlinux.tar.gz archlinux:syui
|
||||
$ docker run -it archlinux:syui /bin/bash
|
||||
```
|
||||
|
||||
### install.sh
|
||||
|
||||
最小構成のinstall scriptです。どこかのurlに置いて、install時にcurlして実行するようにすれば便利です。
|
||||
|
||||
```sh
|
||||
$ curl -sLO arch.example.com/install.sh
|
||||
$ chmod +x install.sh
|
||||
$ ./install.sh
|
||||
```
|
||||
|
||||
```sh:install.sh
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# 変数定義
|
||||
DISK="/dev/sda"
|
||||
HOSTNAME="ai-arch"
|
||||
USERNAME="ai"
|
||||
|
||||
# パーティション作成(自動)
|
||||
parted $DISK mklabel gpt
|
||||
parted $DISK mkpart ESP fat32 1MiB 1GiB
|
||||
parted $DISK set 1 esp on
|
||||
parted $DISK mkpart primary linux-swap 1GiB 5GiB
|
||||
parted $DISK mkpart primary ext4 5GiB 100%
|
||||
|
||||
# ファイルシステム作成
|
||||
mkfs.fat -F32 ${DISK}1
|
||||
mkswap ${DISK}2
|
||||
mkfs.ext4 ${DISK}3
|
||||
|
||||
# マウント
|
||||
mount ${DISK}3 /mnt
|
||||
mkdir -p /mnt/boot
|
||||
mount ${DISK}1 /mnt/boot
|
||||
swapon ${DISK}2
|
||||
|
||||
# インストール
|
||||
pacstrap -K /mnt base linux linux-firmware base-devel vim networkmanager grub efibootmgr
|
||||
|
||||
# 設定
|
||||
genfstab -U /mnt >> /mnt/etc/fstab
|
||||
|
||||
arch-chroot /mnt /bin/bash << EOF
|
||||
ln -sf /usr/share/zoneinfo/Asia/Tokyo /etc/localtime
|
||||
hwclock --systohc
|
||||
echo "ja_JP.UTF-8 UTF-8" >> /etc/locale.gen
|
||||
locale-gen
|
||||
echo "LANG=ja_JP.UTF-8" > /etc/locale.conf
|
||||
echo "$HOSTNAME" > /etc/hostname
|
||||
grub-install --target=x86_64-efi --efi-directory=/boot --bootloader-id=ARCH
|
||||
grub-mkconfig -o /boot/grub/grub.cfg
|
||||
systemctl enable NetworkManager
|
||||
useradd -m -G wheel $USERNAME
|
||||
EOF
|
||||
```
|
||||
|
1675
my-blog/content/posts/2025-08-09-9935c5b5.md
Normal file
151
my-blog/content/posts/2025-08-12-game.md
Normal file
@@ -0,0 +1,151 @@
|
||||
---
|
||||
title: "game system v0.4.0"
|
||||
slug: "game"
|
||||
date: "2025-08-12"
|
||||
tags: ["ue"]
|
||||
draft: false
|
||||
---
|
||||
|
||||
今回は、game systemのupdateをまとめます。
|
||||
|
||||
分かりづらいので、game systemは全体で同じversionに統一しています。
|
||||
|
||||
まず、大きく分けて3つのシステムをupdateしました。
|
||||
|
||||
- yui system: キャラクターのバースト(必殺技)を実装
|
||||
- at system: ログイン処理とデータ構造の作成
|
||||
- world system: 場所ごとにBGMを再生するシステムの構築
|
||||
- world system: 惑星に雪や雨を降らせることに成功
|
||||
|
||||
<iframe width="100%" height="415" src="https://www.youtube.com/embed/eXrgaVNCTA4?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>
|
||||
|
||||
## 戦闘シーンの作成
|
||||
|
||||
1キャラクターにつき、1スキル、1バースト、1ユニークというのは決まっていました。これは`yui system`の領域。
|
||||
|
||||
アイの属性はアイ属性なので、テーマカラーは黄色です。属性自体は`ai system`の領域ですが、現在、関連反応のシステムまでは実装していません。
|
||||
|
||||
今回はバーストの作成、ダメージ表記、enemy(敵)の撃破までを実装しました。最初から作り変えたので大変でした。
|
||||
|
||||
<iframe src="https://blueprintue.com/render/l7_xvfbp" scrolling="no" allowfullscreen style="width:100%;height:400px"></iframe>
|
||||
|
||||
## 音楽システムの実装
|
||||
|
||||
これは`world system`の領域で、開発中は`PlayerStart`で各位置に瞬間移動して確認しています。これはアイでなければ設定上無理でした。
|
||||
|
||||
具体的には、PlayerStartのtagと音楽を同じ名前で登録します。そして、playerに最も近いものを再生します。効率的でシンプルですが、少し欠陥があるシステムかもしれません。これは、enemy-hpの表示と連動させています。現在、鳴らしているbgmの名前がわかれば表示できるというわけですね。enemy-bossもPlayerStartのtagで同じ名前で置いてあります。
|
||||
|
||||
<iframe src="https://blueprintue.com/render/x80534fn" scrolling="no" allowfullscreen style="width:100%;height:400px"></iframe>
|
||||
|
||||
原作の設定は、ゲーム開発中も適用されます。アイを動かして空を飛んでいますが、あれはアイだからできるのであって、宇宙空間の移動とかもそうです。
|
||||
|
||||
原作の設定を紹介しておきます。
|
||||
|
||||
### 原作の設定: アイはなぜ空を飛べるのか
|
||||
|
||||
アイはものすごい質量を持ちます。空を飛んでいるというより、地球を持ち上げて、空を飛んでいるように見せている、という感じで飛行しています。
|
||||
|
||||
いやいや、それじゃあ、地球はアイに落ちるだけで、空どころか地面に落ちるだろう、と言われそうですが、地球というのは宇宙から見るとすごいスピードで回転、移動しています。
|
||||
|
||||
そして、宇宙で星と星がぶつかるときは、決して直接ドカーンと衝突するわけではないのです。
|
||||
|
||||
お互いに距離を取りながらぐるぐる回って、やがてぶつかる、そんなイメージ。
|
||||
|
||||
質量と質量の間があるわけですね。
|
||||
|
||||
アイが瞬間的に自身の質量の一部を現すと、間ができ、対象の星の質量を計算しながら、それを持ち上げて動かすような感じで移動しています。
|
||||
|
||||
### 原作の設定: アイはなぜ宇宙空間でも平気なの
|
||||
|
||||
それはアイの体の周りには極小の大気の膜があるためだとされています。超重力で圧縮された大気の膜があるため、宇宙空間、その他一切の外的影響をあまり受けません。
|
||||
|
||||
アイは常に、自分の星の中にいるのと同じ状態、といえばいいのでしょうか。そんな感じです。
|
||||
|
||||
## データ構造の変更
|
||||
|
||||
次に、`at system`です。ゲームデータを再構築しました。
|
||||
|
||||
ゲームデータは主にsystem情報とuser情報に分けられ、jsonで管理されます。
|
||||
|
||||
各パラメータですが、ゲームに必要な値を`cp`として圧縮することにしました。このcpをsystem.jsonあるいはゲーム自体で各キャラクターの設定、つまり、`attack: 10%, hp: 20%, skill: 70%`などで分けられます。これが最もわかりやすく、最も効率的な方法だと考えました。
|
||||
|
||||
```json:user.json
|
||||
{
|
||||
"character": [
|
||||
{ "id": 0, "cp": 100 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
```json:system.json
|
||||
{
|
||||
"character": [
|
||||
{ "id": 0, "name": "ai", "ability": "ai" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"ability": [
|
||||
{ "id": 0, "name": "ai" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
これをログインシステムに連動させました。
|
||||
|
||||
このサイトで`at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.verse.user`を検索してもらえればわかります。
|
||||
|
||||
```sh
|
||||
# ゲームシステム
|
||||
at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.verse
|
||||
|
||||
# aiのアカウント
|
||||
at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.verse.user/6qyecktefllvenje24fcxnie
|
||||
|
||||
# syuiのアカウント
|
||||
at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.verse.user/vzsvtbtbnwn22xjqhcu3vd6y
|
||||
```
|
||||
|
||||
ちなみに、私のアカウントである`syui.syui.ai`ではアイは使用できません。現在使用できるキャラは`dragon`のみ。
|
||||
|
||||
現在、アイを使用できるのは、アイのアカウントのみです。この方針は可能な限り維持されるでしょう。
|
||||
|
||||
## 惑星に雨や雪を降らせる
|
||||
|
||||
これはなかなか苦労していたのですが、実装できました。
|
||||
|
||||
まず、有効にすると宇宙空間でも雨が降ってしまいます。止めると惑星内で雨が降りません。
|
||||
|
||||
これを解消するには、player locationと0原点のdistanceから条件をつけ、雲の下、雲の上と定義します。調整が必要。
|
||||
|
||||
そして、udsのweather、特に`Apply Weather Changes Above Cloud Layer`が重要で、`Apply Clouds`の値を調整します。
|
||||
|
||||
<iframe src="https://blueprintue.com/render/dstkcaia" scrolling="no" allowfullscreen style="width:100%;height:400px"></iframe>
|
||||
|
||||
## 実体ある太陽のatmosphere問題
|
||||
|
||||
まず、私が使っている実体ある太陽にはatmosphereがついています。
|
||||
|
||||
これはフレアなどを設定しています。
|
||||
|
||||
しかし、これを地球から見た場合、その大気圏を通すと、非常に見栄えが悪い変なカクカクした光が映り込みます。
|
||||
|
||||
この解消も非常に苦労しました。例えば、これを`BP_Sun`としましょう。これは起動時にすべての値を設定します。ゲームプレイ中に値の調整をすることは考えられていません。当然と言えるでしょう。
|
||||
|
||||
しかし、私のシステムでは、太陽のatmosphereを調整する必要があります。非常に複雑な設定は、リセットでしか解消できないということになりました。そして、udsに入れている小アクタコンポーネントの太陽は、リセットも容易ではありません。
|
||||
|
||||
色々な処理を作り、先程作った地表からの現在地の割り出しを条件に、これをリセットする処理をねじ込みました。
|
||||
|
||||
|
||||
<iframe src="https://blueprintue.com/render/nsqu0hnf" scrolling="no" allowfullscreen style="width:100%;height:400px"></iframe>
|
||||
|
||||
## 動画で確認
|
||||
|
||||
<iframe width="100%" height="415" src="https://www.youtube.com/embed/H1efWYmIugc?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>
|
||||
|
||||
1. BGMが切り替わる
|
||||
2. 物理ボックスが反応
|
||||
3. 敵へのダメージ
|
||||
4. ボスの撃破
|
||||
5. 雨が雲の上では止まる
|
||||
6. ログインでatprotoのアカウントを反映
|
||||
7. プレイでatprotoの情報を更新
|
180
my-blog/content/posts/2025-08-18-game.md
Normal file
@@ -0,0 +1,180 @@
|
||||
---
|
||||
title: "なぜ自作ゲームのsystemを作るのか"
|
||||
slug: "game"
|
||||
date: "2025-08-18"
|
||||
tags: ["ue"]
|
||||
draft: false
|
||||
---
|
||||
|
||||
現在、自作ゲームを開発しています。
|
||||
|
||||
どういうゲームかと一言でいうと現実の反映を目指しています。
|
||||
|
||||
現実の反映とは何でしょう。例えばゲームではblueskyのようなsnsのアカウントでログインできます。ゲームの世界は現実に合わせた惑星形式です。キャラクターの属性は現実にある物質です。原子や中性子など。
|
||||
|
||||
今回は、なぜ自作ゲームのsystemを作っているのか解説します。
|
||||
|
||||
## 一つの青写真
|
||||
|
||||
私は`2023-12-04`あたりからunreal engine(ue)を触り始めました。
|
||||
|
||||
当時、ゲームでこんなことがやりたいなと思って作った画像があります。
|
||||
|
||||

|
||||
|
||||
https://syui.github.io/blog/post/2023/12/04/ue-vs-unity/
|
||||
|
||||
今ではゲーム作りに対する考え方も変わりましたが、上のイメージは頭の中にずっと残っていて、ようやく、イメージ通りの戦闘シーンを作成できました。
|
||||
|
||||
<iframe width="100%" height="415" src="https://www.youtube.com/embed/tBsYgqI1uSc?rel=0&showinfo=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>
|
||||
|
||||
- 敵の砲撃は剣で弾き返す事ができます。反射したものが当たると敵もダメージを受けます
|
||||
- 敵の砲撃が激しすぎるためバースト時の無敵時間を長めに設定しています
|
||||
|
||||
### 原作の設定: アイのバースト
|
||||
|
||||
アイのバースト、つまり必殺技について解説します。自分で作ったカード、`超新星`というタイトルをモデルにした技ですが、技名自体は`中性子`になります。具体的には周囲の原子から中性子を取り出し、それを一点に集めて放つ技。ようは、中性子星を作り出しそれを飛ばしています。これがアイのバースト、中性子です。このゲームは麻雀要素を入れようと考えていて、それはバーストに適用されます。役が揃うと、超新星に変化するという実装を考えています。
|
||||
|
||||
### キャラクターの音声
|
||||
|
||||
アイはかなり低い確率でスキルやバースト時に音声が付いています。これをキャラクターの音声システムとしましょう。
|
||||
|
||||
キャラクターは性格に応じて音声の発動頻度が異なります。アイは最も音声確率が低いランクに割り当てられています。(コンピュータで作っているため粗が見えても困るので)
|
||||
|
||||
## 広い世界と狭い世界
|
||||
|
||||
### なぜアイを操作するゲームはつまらないのか
|
||||
|
||||
私がゲーム作りを始めた理由はいくつかありますが、もし現実にアイというキャラクターがいたら一体どんな感じになるのだろう。それを体感してみたいと思ったからです。
|
||||
|
||||
この思いは面白いゲームを作ることにはまるで寄与しないものでしたが、私はそれを作ることにしました。
|
||||
|
||||
ここで、なぜアイを操作するゲームがつまらないのか簡単に説明します。
|
||||
|
||||
### 広い世界は面白くない
|
||||
|
||||
私は普段、アイを使ってゲームを操作し、世界を飛び回り、作っています。
|
||||
|
||||
なぜなら、そのほうが開発に便利なのでそうしています。また都合がいいことに、アイというキャラクターは設定上、そういう事が可能となります。
|
||||
|
||||
しかし、先程も述べたように、そのようなゲームは恐ろしくつまらない、ということです。
|
||||
|
||||
とすれば、重要なのは小さくても、しっかりしたゲームを作ること。上のようなゲームを作ってはいけないのです。
|
||||
|
||||
広い世界、無制限の移動ではなく、狭くてもしっかりした世界を作らなければいけない。面白いゲームとはそういうものです。
|
||||
|
||||
### 最初の思いと面白いゲーム
|
||||
|
||||
次は、初めての思い、初心を大切にすることを考えていきます。
|
||||
|
||||
開発者が作りたいゲームと面白いゲームは大抵の場合、両立しません。
|
||||
|
||||
例えば、映像美、見せることと実際に面白いことは違うのです。
|
||||
|
||||
誰もが初めて何かをする時、そこには各人の思いがあります。それは小さいものであれ大きいものであれ、そこに意識がある。
|
||||
|
||||
それは、時間が経つと忘れてしまうものですが、心の奥深くに残っている。
|
||||
|
||||
しかし、大抵の場合、そういった思いと、誰もが面白いと思う人気ゲームを作るという思いは相反します。
|
||||
|
||||
つまり、心の奥底に眠っている最初の思いと、面白いゲームは違うものだし、また、ゲームに限らず、これは色んな作品に言えることだと思います。
|
||||
|
||||
これを勘違いして「自分の思いは、他人にも面白いはずだ」とそう思い込むのは誤りです。
|
||||
|
||||
では、どうすればいいのでしょう。最初の思いを捨て、面白いゲームを分析して世間に受け入れられるものを作るべきなのでしょうか。
|
||||
|
||||
これはyesとも言えるし、noとも言えます。
|
||||
|
||||
優先順位としては、間違いなく面白いゲームを作るべきです。個人開発者のよくわからないこだわりなどさっさと捨てるべき、そう思います。
|
||||
|
||||
ですが、本当にそれでいいのでしょうか。
|
||||
|
||||
私はそれはもったいないと思います。
|
||||
|
||||
したがって、できる限りそこを両立させる方法を探すべきだと思います。
|
||||
|
||||
私はここで`分離`という方法を使います。世界(方針)を切り離すのです。
|
||||
|
||||
これが広い世界と狭い世界、個人開発の指針になります。一見矛盾するこの2つの世界の分離と統合を考えます。
|
||||
|
||||
最初の思い、本当に作りたかったもの、楽しくも面白くもないけど、自分の世界。そして、小さく作る面白いゲームの世界。
|
||||
|
||||
自分で作ったものを無駄にしないようシステムという4つの柱を立てました。
|
||||
|
||||
4つの柱は、根源的な価値観によるもの。広いも狭いも、面白いも面白くないも関係ありません。systemは以下のようになります。これらはどのゲームにも当てはめられ、使用できることを目指して設計されます。
|
||||
|
||||
- `world system`: 現実に合わせた世界を構築するシステム。最初は地球、太陽、月から生成される。ゲームエンジンの背景に月の絵を動かすという常識を変更する。実体ある月をその空間に置くことで背景を生成。惑星システムを構築する。
|
||||
- `ai system`: 属性やゲーム性を構築するシステム。
|
||||
- `yui system`: 唯一性という価値観から構築されるシステム。現実をゲームに、ゲームを現実に反映することで自然と実現される。
|
||||
- `at system`: プレイヤーのアカウントをatprotoというprotocolの理解により構築する。protocolは容易には無くならないし変更されないもの。`@`というdomainで繋がるユーザーアカウントのシステム。blueskyというsnsに利用されている。
|
||||
|
||||
### systemを作ろう
|
||||
|
||||
ゲームはsystemの集合体で作るのが一番いい。
|
||||
|
||||
開発では、まとめることが重要になります。
|
||||
|
||||
systemとは関数やコンポーネント、変数の集まりです。それは結果であって目的ではありません。
|
||||
|
||||
目的の一つはわかりやすさ。例えば、敵を倒した時、アイテムをドロップさせる処理を作ります。個別に敵のBPに作るのではなく、systemとして作っておくとよいでしょう。1と押せば1つのアイテムがドロップします。3と押せば3つです。内容もランダムかつランクを付けましょう。Aランク-Cランクのアイテムです。
|
||||
|
||||
つまり、そのsystemに3Bと伝えると、3つのアイテムがドロップし、Bランクのアイテムがドロップしやすい、という結果が出力されます。アイテム一覧もすべてそのsystemが管理し、簡単に設定できます。
|
||||
|
||||
ゲームを作るというのは、systemを作ること。それは単体の実行ではありません。個人開発の場合は特に設計を重視し、まとめることを重視します。
|
||||
|
||||
これが何かというと、一つはルールを作ることです。
|
||||
|
||||
1キャラクターにつき1スキル、1バースト。例外は認めない。このようなルールです。そして、そのルールに基づいたsystemを設計し、例えば、キャラクターならキャラクターの統一管理を目指します。統一管理というのは、数字やobjectを入れれば設定は完了です。その通りにすべてのキャラクターが共通動作するようにしておこう、そこを目指そうということです。
|
||||
|
||||
これがsystemを作るということです。
|
||||
|
||||
私は、敵(enemy)を作っている時間がありませんから、enemyもcharacterにまとめることにしました。
|
||||
|
||||
そのため、少し動きの調整が難しかったりもしますが、この方向性で間違いないと思います。
|
||||
|
||||
シーンやムービー、ストーリーは広い世界(アイで操作する世界)にまとめ、enemyもplayerが操作できるcharacterにまとめ、単体で作るものを減らし、すでにあるもの、作ったものは他の役割も担えるようにしていきましょう。このような考え方が個人開発では重要になってくると思います。
|
||||
|
||||
<iframe width="100%" height="415" src="https://www.youtube.com/embed/L6eZUZNCOH8?rel=0&showinfo=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>
|
||||
|
||||
### やることを明確に
|
||||
|
||||
個人開発者には、できることとできないことがあります。
|
||||
|
||||
市販のゲームは、あらゆる専門家が大量に集まり一緒に作っているゲームです。個人開発で全部はできません。
|
||||
|
||||
しかし、できないからと言って、手を抜くのも違いますよね。
|
||||
|
||||
確かに、私が作りたかったゲームは面白くないかもしれない。けど、一見すると面白そうに見えます。
|
||||
|
||||
映像美や見せることと実際に面白いことは違います。しかし、映像美は別に悪いことではありません。それはゲームとしては面白くないけど、見せる力があると思います。シーンやムービーとして利用できるのではないでしょうか。
|
||||
|
||||
私はシーンやムービーを作っている時間はありません。しかし、私が今まで作ってきた広い世界はそういったことに使えばいい。狭い世界の背景にも使えます。
|
||||
|
||||
いつか行けるかもしれない広い世界。理想と現実。
|
||||
|
||||
最初から理想だけあっても、それは面白くない。最初から現実だけあってもそれはただのゲームです。面白いゲームとは、現実と理想のバランス。あるいは、その過程を作ることにあるのではないでしょうか。
|
||||
|
||||
最初の夢を持ち続けることも、そして、多くの人が楽しめるものを作る事も両方大切です。初心を捨てず分離して、新たに面白いゲームを目指して作り始めること。そして、最終的に統合できる道筋を思い描けるなら。そんなことを思います。
|
||||
|
||||
次は、小さくも完璧で、狭くても全てに由来がある、そんな世界を作っていこうと思っています。
|
||||
|
||||
### 狭い世界をどうやって作っていこう
|
||||
|
||||
ポイントは、カラフル+ポップだと思います。小さい+完璧もポイントですね。また、動作は非常にゆっくりがいいのではないでしょうか。
|
||||
|
||||
つまり、小さいが完璧に動作し、カラフルでポップな世界。それがここでいう狭い世界になります。
|
||||
|
||||
私はこの辺のこともあまり知りませんから、epicの[stack o bot](https://www.fab.com/ja/listings/b4dfff49-0e7d-4c4b-a6c5-8a0315831c9c)というテンプレートをもとに学習しながら作っていこうと考えています。
|
||||
|
||||
<iframe width="100%" height="415" src="https://www.youtube.com/embed/6tO0S7IOC9w?rel=0&showinfo=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>
|
||||
|
||||
## game system v0.4.3
|
||||
|
||||
- ps5 controllerの対応
|
||||
- game animation sampleとstack o botの統合
|
||||
- item drop system
|
||||
- character audio system
|
||||
- sword reflection
|
||||
- character dragon skill (enemy)
|
||||
- bgm systemの修正
|
||||
|
101
my-blog/content/posts/2025-08-23-game.md
Normal file
@@ -0,0 +1,101 @@
|
||||
---
|
||||
title: "自作ゲームを開始から終了までプレイ"
|
||||
slug: "game"
|
||||
date: "2025-08-23"
|
||||
tags: ["ue"]
|
||||
draft: false
|
||||
---
|
||||
|
||||
自作ゲームを開発しています。
|
||||
|
||||
今回は開始から終了までの大体の流れができたのでプレイしてみました。
|
||||
|
||||
<iframe width="100%" height="415" src="https://www.youtube.com/embed/FTX1CrzKBy8?rel=0&showinfo=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>
|
||||
|
||||
## ゲームの流れを解説
|
||||
|
||||
1. 物語は宇宙から始まる。プレイヤーの村に突然宇宙船がやってきて、プレイヤーが連れ去られる。
|
||||
2. ここは船内にある檻の中。監視兵が慌ただしい。「おい、あれが出たって」「まさか」などの会話。チラと窓の外に目をやる。すると何かが光ったような気がした。
|
||||
3. (ここでプレイヤーは初回のみアイを操作可能になる。ゲーム開始時にすぐに操作可能にすることが重要だと思ったので、シーンの作成はやめて、プレイヤーに戦艦を撃破してもらうことに)
|
||||
4. 艦内は爆発し、星に落ちていく。目が覚めると...そこからステージが始まる。(ここからプレイヤーのキャラクターに切り替わる。今回はアイのアカウントなので、アイになっているが本来は違う)
|
||||
5. ステージの背景に小さな子がぐーぐ寝ている。(先ほど操作したアイは変身時の金髪輪っかなので、黒髪に戻っているアイを見てもプレイヤーにはわからない)
|
||||
6. ステージを進み、ドラゴンを倒してゲームは終了。花火っぽいものを打ち上げ、ポーズを決める。その後、ゲーム終了まで自由操作。アイテムのドロップがある。
|
||||
|
||||
## 面白いゲームを目指して
|
||||
|
||||
インベーダーやマリオなど今の技術では簡単に作れそうなレトロゲームがあります。
|
||||
|
||||
それらが面白いのかと言われれば、私は面白いと思います。
|
||||
|
||||
とはいえ、今そういったもので遊ぶかというと、それは違うと思います。
|
||||
|
||||
しかし、個人開発者はまずその段階に到達する必要があるのではないかと感じます。
|
||||
|
||||
では、レトロゲームの面白さについて、改めて分析してみることにしましょう。
|
||||
|
||||
レトロゲームなんて、AIを使えば簡単に作れますよ。そんな声が聞こえてきそうですが、それは少し違うと思います。
|
||||
|
||||
例えば、ステージ1を作れても、ステージ2,3,4、そして、ラストのステージまで、市販の初代マリオと同じように作っているのでしょうか。
|
||||
|
||||
それができていないなら、それは作れていないということです。
|
||||
|
||||
そして、ステージだけがマリオじゃないですよね。私がプレイしたことがあるスーパーマリオワールドはボスを倒すと演出がありました。
|
||||
|
||||
そこには、物語があり、花火が打ち上がり、紙吹雪が舞い、主人公がポーズを決めるのです。意外とゲーム自体よりそういったものを含めて面白いゲームなのであって、それが重要なのだと思います。
|
||||
|
||||
個人開発者の多くは私を含め、そういった面白いゲームを作れているのかというと、できていないと思うのです。
|
||||
|
||||
もちろん、色々な人がいますから、できている人もいると思います。しかし、私にはできていない。そこまで作れていないし、レトロゲームの域にすら到達していません。
|
||||
|
||||
## 面白いものと売れるものは違う
|
||||
|
||||
では、面白いものを作れば、それで売れるのかというと、それもまた違うと思います。
|
||||
|
||||
既にあるゲームのパクリ、それはそれで面白いゲームになると思います。しかし、今更レトロゲームを作っても、誰もプレイしないと思います。
|
||||
|
||||
面白いゲームと人気が出ることは違うのです。
|
||||
|
||||
そこで重要になるのがオリジナリティという要素ではないでしょうか。
|
||||
|
||||
したがって、段階があるとしたら、面白いゲームの域に到達する。その後、オリジナリティの域に到達する。あるいは、同時にそれをこなす必要があるのだと思います。
|
||||
|
||||
とはいえ、まずは面白いゲームを作ること。せめてその域に到達したいですよね。そして、レトロゲームにも十分に面白い要素は揃っているので、それらを参考にするのが良いと判断しました。
|
||||
|
||||
## 人気が出ることと利益が出ることもまた違う
|
||||
|
||||
そういえば、収益化もまた別の話だよなと思ったので書きます。
|
||||
|
||||
確かに、それは必須条件かもしれませんが、それがあれば必ずというものではないと思います。確かに寄与する部分は少なからずあるとは思うけど。
|
||||
|
||||
例えば、snsをみていると、すごいインプレッション、注目を集めたのに、売れなかった漫画がたくさんあります。つまり、人気は出たが、利益は出なかったケースだと思います。
|
||||
|
||||
したがって、収益化までの道のりもまた長いのではないか。大変なのかもしれない。そんなふうに思うのです。
|
||||
|
||||
これを短絡的な見通しで「面白ければ売れる」などと考えていると、当てが外れるかもしれない。そのへんはあまり期待しないほうがいいかも。
|
||||
|
||||
段階的にそれぞれの戦略を考えていくのが良いのではないかな。
|
||||
|
||||
`面白い -> 人気が出る -> 利益が出る`...その間にも高い壁がある。
|
||||
|
||||
## オリジナリティはどうやって出すのか
|
||||
|
||||
例えば、制約からです。
|
||||
|
||||
私は設計において、いくつかの決め事を作りました。例えば、以下のルールがあります。
|
||||
|
||||
1. 物理法則に反しない
|
||||
|
||||
ゲームで物を浮かせるのは簡単だ。しかし、この世界は現実の反映を目指している。したがって、すぐに物を浮かせたり、あるいはテレポートしたり、それをやってはいけない。そういったものには必ず、原理を説明できなければならない。特殊なアイテムが必要となる。このアイテムをアイの家に3つ置いてあるとしよう。この場合、その3つをマップに置くと、その世界にはもうない。使えない。そのようなルールだ。その世界の重要なアイテムはアイが持っていて、作っている。無限には湧いてこない。制限がある。これはatprotoに保存し、公開しておくのもおもしろいかもしれないな。こういったことがその世界の由来につながるのだと思う。
|
||||
|
||||
2. マップは一つ
|
||||
|
||||
マップは必ず一つの中で完結させること。世界は一つというルール。たくさんのマップを分けてはいけない。不便でも一つのマップの中だけで世界を作ろう。マップを分けてはいけない。宇宙も地上も一つにすること。シーンやムービーを作るときも同じ。違うマップでそれをやってはいけない。
|
||||
|
||||
## 今回のゲーム作りで意識したこと
|
||||
|
||||
- game system v0.4.4
|
||||
|
||||
1. レトロゲームの面白さを必要最小限で実装
|
||||
2. オリジナリティを融合(このゲームのテーマである宇宙、そして物語)
|
||||
3. すべての実装を各システムで動かす
|
||||
|
107
my-blog/content/posts/2025-09-05-plc.md
Normal file
@@ -0,0 +1,107 @@
|
||||
---
|
||||
title: "plcにhandle changeを反映する"
|
||||
slug: "plc"
|
||||
date: "2025-09-05"
|
||||
tags: ["atproto"]
|
||||
draft: false
|
||||
---
|
||||
|
||||
いつまで経ってもbsky.teamのplcにhandle changeが反映されないので色々やってみました。
|
||||
|
||||
結論から言うと、`PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX`を使用し、`base58`のrotation-keyを作成後に、indigoにある`goat plc`を使用します。
|
||||
|
||||
1. `goat key generate --type secp256k1`で生成されたキーを分析
|
||||
2. そのキーから正しいmulticodecプレフィックスを抽出
|
||||
3. PDSのhex keyに同じプレフィックスを適用
|
||||
|
||||
```sh
|
||||
$ go install github.com/bluesky-social/indigo/cmd/goat@latest
|
||||
```
|
||||
|
||||
```sh
|
||||
$ goat account login -u syui.syui.ai -p $PASS --pds-host https://syu.is
|
||||
|
||||
$ goat plc history did:plc:vzsvtbtbnwn22xjqhcu3vd6y
|
||||
did:key:zQ3shZj81oA4A9CmUQgYUv97nFdd7m5qNaRMyG16XZixytTmQ
|
||||
|
||||
$ goat plc update did:plc:vzsvtbtbnwn22xjqhcu3vd6y \
|
||||
--handle syui.syui.ai \
|
||||
--pds https://syu.is \
|
||||
--atproto-key did:key:zQ3shZj81oA4A9CmUQgYUv97nFdd7m5qNaRMyG16XZixytTmQ > plc_operation_syui.json
|
||||
|
||||
# もしミスった時は前の操作を無効化して再実行
|
||||
$ goat plc update did:plc:vzsvtbtbnwn22xjqhcu3vd6y \
|
||||
--handle syui.syui.ai \
|
||||
--pds https://syu.is \
|
||||
--atproto-key did:key:zQ3shZj81oA4A9CmUQgYUv97nFdd7m5qNaRMyG16XZixytTmQ \
|
||||
--prev "bafyreifomvmymylntowv2mbyvg5i7wgv375757l574gevcs7qbysbqizk4" > plc_operation_syui_nullify.json
|
||||
```
|
||||
|
||||
```sh
|
||||
source base58_env/bin/activate
|
||||
|
||||
python3 -c "
|
||||
import base58
|
||||
|
||||
# 生成されたsecp256k1キーを分析
|
||||
generated_secp256k1 = '${zXXX...}'
|
||||
decoded = base58.b58decode(generated_secp256k1[1:]) # 'z'を除く
|
||||
|
||||
print('Generated secp256k1 key analysis:')
|
||||
print(' Total length:', len(decoded))
|
||||
print(' Full hex:', decoded.hex())
|
||||
|
||||
# 32バイトの鍵データを除いたプレフィックスを抽出
|
||||
if len(decoded) > 32:
|
||||
prefix = decoded[:-32]
|
||||
key_data = decoded[-32:]
|
||||
print(' Prefix hex:', prefix.hex())
|
||||
print(' Prefix length:', len(prefix))
|
||||
print(' Key data length:', len(key_data))
|
||||
|
||||
pds_rotation_hex = '${PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX}'
|
||||
pds_rotation_bytes = bytes.fromhex(pds_rotation_hex)
|
||||
|
||||
prefixed_rotation_key = prefix + pds_rotation_bytes
|
||||
multibase_rotation_key = 'z' + base58.b58encode(prefixed_rotation_key).decode()
|
||||
|
||||
print('\\nConverted PDS rotation key:')
|
||||
print(' Multibase:', multibase_rotation_key)
|
||||
|
||||
else:
|
||||
print(' No prefix found, key is raw')
|
||||
"
|
||||
|
||||
deactivate
|
||||
```
|
||||
|
||||
```sh
|
||||
$ PDS_ROTATION_KEY=zXXX...
|
||||
|
||||
$ goat plc sign --plc-signing-key "$PDS_ROTATION_KEY" plc_operation_syui.json > plc_signed_syui.json
|
||||
$ goat plc submit --did did:plc:vzsvtbtbnwn22xjqhcu3vd6y plc_signed_syui.json
|
||||
success
|
||||
|
||||
$ goat plc history did:plc:vzsvtbtbnwn22xjqhcu3vd6y
|
||||
```
|
||||
|
||||
## 手順をおさらい
|
||||
|
||||
1. `plc_operation.json`を作成
|
||||
2. `plc_operation.json`と`PDS_ROTATION_KEY`を使用し、`plc_signed.json`を作成
|
||||
3. `plc_signed.json`を使用し、plcを更新
|
||||
|
||||
## plcを確認
|
||||
|
||||
```sh
|
||||
did=did:plc:vzsvtbtbnwn22xjqhcu3vd6y
|
||||
curl -sL "https://plc.directory/$did"|jq .alsoKnownAs
|
||||
curl -sL "https://plc.syu.is/$did"|jq .alsoKnownAs
|
||||
[
|
||||
"at://syui.syui.ai"
|
||||
]
|
||||
[
|
||||
"at://syui.syui.ai"
|
||||
]
|
||||
```
|
||||
|
31
my-blog/content/posts/2025-09-07-ps5-controller.md
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
title: "ue5でdualsenseを使う"
|
||||
slug: "ps5-controller"
|
||||
date: "2025-09-07"
|
||||
tags: ["ue"]
|
||||
draft: false
|
||||
---
|
||||
|
||||
ps5-controllerは`dualsense`というらしい。ue5で使うには、以下のpluginを使います。fabかgithubのreleaseからpluginフォルダに入れてbuildするか2つの方法があります。
|
||||
|
||||
## dualsense plugin
|
||||
|
||||
- [https://github.com/rafaelvaloto/WindowsDualsenseUnreal](https://github.com/rafaelvaloto/WindowsDualsenseUnreal)
|
||||
- [https://github.com/rafaelvaloto/GamepadCoOp](https://github.com/rafaelvaloto/GamepadCoOp)
|
||||
|
||||

|
||||
|
||||
`v1.2.10`からmultiplayを意識した`GamepadCoOp`との統合が行われました。
|
||||
|
||||
コントローラーのライトをキャラクター切り替え時に変更する処理を入れました。
|
||||
|
||||
<iframe src="https://blueprintue.com/render/tx_q1evf" scrolling="no" allowfullscreen style="width:100%;height:400px"></iframe>
|
||||
|
||||
## dualsenseの分解
|
||||
|
||||
最近、ドリフト問題が発生していたこともあり、何度も分解していました。
|
||||
|
||||
よって、このタイプのコントローラーなら簡単に修理できるようになりました。
|
||||
|
||||
今後も`dualsense`を使用していく可能性は高いですね。
|
||||
|
123
my-blog/content/posts/2025-09-10-comfyui.md
Normal file
@@ -0,0 +1,123 @@
|
||||
---
|
||||
title: "comfyuiでwan2.2を試す"
|
||||
slug: "comfyui"
|
||||
date: "2025-09-10"
|
||||
tags: ["comfyui"]
|
||||
draft: false
|
||||
---
|
||||
|
||||
comfyuiにwan2.2が来ていたので試してみました。wanがcomfyuiの公式に採用されているので、導入が簡単になっています。
|
||||
|
||||
|
||||
今回は爆速になったLoRA採用でいきます。なお、無効化ノードを外すとクオリティ重視の設定になります。
|
||||
|
||||
関係ありませんが、comfyui公式ページのコメントシステムは[giscus/giscus](https://github.com/giscus/giscus)を使用しているようですね。
|
||||
|
||||
# comfyui
|
||||
|
||||
```sh
|
||||
$ git clone https://github.com/comfyanonymous/ComfyUI
|
||||
$ cd ComfyUI
|
||||
$ winget install python.python.3.13
|
||||
$ pip uninstall torch torchaudio
|
||||
$ pip install --pre torch torchvision torchaudio --index-url https://download.pytorch.org/whl/nightly/cu129
|
||||
$ pip install -r requirements.txt
|
||||
$ python main.py
|
||||
```
|
||||
|
||||
もしvenvを使用する場合
|
||||
|
||||
```sh
|
||||
$ python -m venv venv
|
||||
$ pip install -r requirements.txt
|
||||
$ python main.py
|
||||
```
|
||||
|
||||
## wan2.2
|
||||
|
||||
基本的にpromptから生成し、bypassを切ると動画を参照できます。
|
||||
|
||||
workflowをdownloadしてcomfyuiで開きます。
|
||||
|
||||
```sh
|
||||
# ComfyUI/user/default/workflows/
|
||||
$ curl -sLO https://raw.githubusercontent.com/Comfy-Org/workflow_templates/refs/heads/main/templates/video_wan2_2_5B_ti2v.json
|
||||
```
|
||||
|
||||
必要なものは公式ページにリンクがあります。
|
||||
|
||||
[https://docs.comfy.org/tutorials/video/wan/wan2_2](https://docs.comfy.org/tutorials/video/wan/wan2_2)
|
||||
|
||||
```sh
|
||||
ComfyUI/
|
||||
├───📂 models/
|
||||
│ ├───📂 diffusion_models/
|
||||
│ │ └───wan2.2_ti2v_5B_fp16.safetensors
|
||||
│ ├───📂 text_encoders/
|
||||
│ │ └─── umt5_xxl_fp8_e4m3fn_scaled.safetensors
|
||||
│ └───📂 vae/
|
||||
│ └── wan2.2_vae.safetensors
|
||||
```
|
||||
|
||||

|
||||
|
||||
<video src="/img/comfyui_wan22_0001.mp4" width="100%" controls></video>
|
||||
|
||||
## wan2-2-fun-control
|
||||
|
||||
これはポーズを動画から作成して動画を作ります。
|
||||
|
||||
```sh
|
||||
$ curl -sLO https://raw.githubusercontent.com/Comfy-Org/workflow_templates/refs/heads/main/templates/video_wan2_2_14B_fun_control.json
|
||||
```
|
||||
|
||||
|
||||
[https://docs.comfy.org/tutorials/video/wan/wan2-2-fun-control](https://docs.comfy.org/tutorials/video/wan/wan2-2-fun-control)
|
||||
|
||||
```sh
|
||||
ComfyUI/
|
||||
├───📂 models/
|
||||
│ ├───📂 diffusion_models/
|
||||
│ │ ├─── wan2.2_fun_control_low_noise_14B_fp8_scaled.safetensors
|
||||
│ │ └─── wan2.2_fun_control_high_noise_14B_fp8_scaled.safetensors
|
||||
│ ├───📂 loras/
|
||||
│ │ ├─── wan2.2_i2v_lightx2v_4steps_lora_v1_high_noise.safetensors
|
||||
│ │ └─── wan2.2_i2v_lightx2v_4steps_lora_v1_low_noise.safetensors
|
||||
│ ├───📂 text_encoders/
|
||||
│ │ └─── umt5_xxl_fp8_e4m3fn_scaled.safetensors
|
||||
│ └───📂 vae/
|
||||
│ └── wan_2.1_vae.safetensors
|
||||
```
|
||||
|
||||
|
||||
## wan2-2-fun-inp
|
||||
|
||||
これは画像から画像を参考にして動画を生成します。
|
||||
|
||||
[https://docs.comfy.org/tutorials/video/wan/wan2-2-fun-inp](https://docs.comfy.org/tutorials/video/wan/wan2-2-fun-inp)
|
||||
|
||||
```sh
|
||||
$ curl -sLO https://raw.githubusercontent.com/Comfy-Org/workflow_templates/refs/heads/main/templates/video_wan2_2_14B_fun_inpaint.json
|
||||
```
|
||||
|
||||
```sh
|
||||
ComfyUI/
|
||||
├───📂 models/
|
||||
│ ├───📂 diffusion_models/
|
||||
│ │ ├─── wan2.2_fun_inpaint_high_noise_14B_fp8_scaled.safetensors
|
||||
│ │ └─── wan2.2_fun_inpaint_low_noise_14B_fp8_scaled.safetensors
|
||||
│ ├───📂 loras/
|
||||
│ │ ├─── wan2.2_i2v_lightx2v_4steps_lora_v1_high_noise.safetensors
|
||||
│ │ └─── wan2.2_i2v_lightx2v_4steps_lora_v1_low_noise.safetensors
|
||||
│ ├───📂 text_encoders/
|
||||
│ │ └─── umt5_xxl_fp8_e4m3fn_scaled.safetensors
|
||||
│ └───📂 vae/
|
||||
│ └── wan_2.1_vae.safetensors
|
||||
```
|
||||
|
||||
## ゲームで動かしたほうがいい
|
||||
|
||||
今回、ゲームのスクショを使って動画を生成してみました。
|
||||
|
||||
しかし、ゲームで動かしたほうがよほど早く確実です。
|
||||
|
63
my-blog/content/posts/2025-09-11-comfyui-nano-banana.md
Normal file
@@ -0,0 +1,63 @@
|
||||
---
|
||||
title: "comfyuiでフィギュア化する"
|
||||
slug: "comfyui"
|
||||
date: "2025-09-11"
|
||||
tags: ["comfyui"]
|
||||
draft: false
|
||||
---
|
||||
|
||||
`gemini`でnano bananaというフィギュア化が流行っています。今回は、それを`comfyui`で再現してみようと思います。
|
||||
|
||||
# comfyui
|
||||
|
||||
```sh
|
||||
$ git clone https://github.com/comfyanonymous/ComfyUI
|
||||
$ cd ComfyUI
|
||||
$ winget install python.python.3.13
|
||||
$ pip uninstall torch torchaudio
|
||||
$ pip install --pre torch torchvision torchaudio --index-url https://download.pytorch.org/whl/nightly/cu129
|
||||
$ pip install -r requirements.txt
|
||||
$ python main.py
|
||||
```
|
||||
|
||||
もしvenvを使用する場合
|
||||
|
||||
```sh
|
||||
$ python -m venv venv
|
||||
$ pip install -r requirements.txt
|
||||
$ python main.py
|
||||
```
|
||||
|
||||
## flux-1-kontext-dev
|
||||
|
||||
[https://docs.comfy.org/tutorials/flux/flux-1-kontext-dev](https://docs.comfy.org/tutorials/flux/flux-1-kontext-dev)
|
||||
|
||||
基本的にcomfyuiで作成した画像にはworkflowが保存されています。ですから、画像があればpromptやbypassなども含めて生成情報がわかります。情報が削除されていない限りは再現することが可能です。
|
||||
|
||||
今回は、`flux-1-kontext-dev`を使用します。
|
||||
|
||||
```sh
|
||||
$ curl -sLO https://raw.githubusercontent.com/Comfy-Org/example_workflows/main/flux/kontext/dev/flux_1_kontext_dev_basic.png
|
||||
```
|
||||
|
||||
```sh
|
||||
📂 ComfyUI/
|
||||
├── 📂 models/
|
||||
│ ├── 📂 diffusion_models/
|
||||
│ │ └── flux1-dev-kontext_fp8_scaled.safetensors
|
||||
│ ├── 📂 vae/
|
||||
│ │ └── ae.safetensor
|
||||
│ └── 📂 text_encoders/
|
||||
│ ├── clip_l.safetensors
|
||||
│ └── t5xxl_fp16.safetensors or t5xxl_fp8_e4m3fn_scaled.safetensors
|
||||
```
|
||||
|
||||
[msg content="prompt: Convert to collectible figure style: detailed sculpting, premium paint job, professional product photography, studio lighting, pristine condition, commercial quality, toy photography aesthetic"]
|
||||
|
||||
以下は`wan2.1`で生成した時の動画。
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
できました。
|
61
my-blog/layouts/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>
|
152
my-blog/layouts/base.html
Normal file
@@ -0,0 +1,152 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ config.language }}">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}{{ config.title }}{% endblock %}</title>
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
|
||||
<!-- Stylesheets -->
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<link rel="stylesheet" href="/css/svg-animation-package.css">
|
||||
<link rel="stylesheet" href="/css/pds.css">
|
||||
<link rel="stylesheet" href="/pkg/icomoon/style.css">
|
||||
<link rel="stylesheet" href="/pkg/font-awesome/css/all.min.css">
|
||||
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header class="main-header">
|
||||
<div class="header-content">
|
||||
<h1><a href="/" class="site-title">{{ config.title }}</a></h1>
|
||||
<div class="logo">
|
||||
<a href="/">
|
||||
<svg width="77pt" height="77pt" viewBox="0 0 512 512" class="likeButton">
|
||||
<circle class="explosion" r="150" cx="250" cy="250"></circle>
|
||||
<g class="particleLayer">
|
||||
<circle fill="#8CE8C3" cx="130" cy="126.5" r="12.5"></circle>
|
||||
<circle fill="#8CE8C3" cx="411" cy="313.5" r="12.5"></circle>
|
||||
<circle fill="#91D2FA" cx="279" cy="86.5" r="12.5"></circle>
|
||||
<circle fill="#91D2FA" cx="155" cy="390.5" r="12.5"></circle>
|
||||
<circle fill="#CC8EF5" cx="89" cy="292.5" r="10.5"></circle>
|
||||
<circle fill="#9BDFBA" cx="414" cy="282.5" r="10.5"></circle>
|
||||
<circle fill="#9BDFBA" cx="115" cy="149.5" r="10.5"></circle>
|
||||
<circle fill="#9FC7FA" cx="250" cy="80.5" r="10.5"></circle>
|
||||
<circle fill="#9FC7FA" cx="78" cy="261.5" r="10.5"></circle>
|
||||
<circle fill="#96D8E9" cx="182" cy="402.5" r="10.5"></circle>
|
||||
<circle fill="#CC8EF5" cx="401.5" cy="166" r="13"></circle>
|
||||
<circle fill="#DB92D0" cx="379" cy="141.5" r="10.5"></circle>
|
||||
<circle fill="#DB92D0" cx="327" cy="397.5" r="10.5"></circle>
|
||||
<circle fill="#DD99B8" cx="296" cy="392.5" r="10.5"></circle>
|
||||
</g>
|
||||
<g transform="translate(0,512) scale(0.1,-0.1)" fill="#000000" class="icon_syui">
|
||||
<path class="syui" d="M3660 4460 c-11 -11 -33 -47 -48 -80 l-29 -60 -12 38 c-27 88 -58 92 -98 11 -35 -70 -73 -159 -73 -169 0 -6 -5 -10 -10 -10 -6 0 -15 -10 -21 -22 -33 -73 -52 -92 -47 -48 2 26 -1 35 -14 38 -16 3 -168 -121 -168 -138 0 -5 -13 -16 -28 -24 -24 -13 -35 -12 -87 0 -221 55 -231 56 -480 56 -219 1 -247 -1 -320 -22 -44 -12 -96 -26 -115 -30 -57 -13 -122 -39 -200 -82 -8 -4 -31 -14 -50 -23 -41 -17 -34 -13 -146 -90 -87 -59 -292 -252 -351 -330 -63 -83 -143 -209 -143 -225 0 -10 -7 -23 -15 -30 -8 -7 -15 -17 -15 -22 0 -5 -13 -37 -28 -71 -16 -34 -36 -93 -45 -132 -9 -38 -24 -104 -34 -145 -13 -60 -17 -121 -17 -300 1 -224 1 -225 36 -365 24 -94 53 -175 87 -247 28 -58 51 -108 51 -112 0 -3 13 -24 28 -48 42 -63 46 -79 22 -85 -11 -3 -20 -9 -20 -14 0 -5 -4 -9 -10 -9 -5 0 -22 -11 -37 -25 -16 -13 -75 -59 -133 -100 -58 -42 -113 -82 -123 -90 -9 -8 -22 -15 -27 -15 -6 0 -10 -6 -10 -13 0 -8 -11 -20 -25 -27 -34 -18 -34 -54 0 -48 14 3 25 2 25 -1 0 -3 -43 -31 -95 -61 -52 -30 -95 -58 -95 -62 0 -5 -5 -8 -11 -8 -19 0 -84 -33 -92 -47 -4 -7 -15 -13 -22 -13 -14 0 -17 -4 -19 -32 -1 -8 15 -15 37 -18 l38 -5 -47 -48 c-56 -59 -54 -81 9 -75 30 3 45 0 54 -11 9 -13 16 -14 43 -4 29 11 30 10 18 -5 -7 -9 -19 -23 -25 -30 -7 -7 -13 -20 -13 -29 0 -12 8 -14 38 -9 20 4 57 8 82 9 25 2 54 8 66 15 18 10 23 8 32 -13 17 -38 86 -35 152 6 27 17 50 34 50 38 0 16 62 30 85 19 33 -15 72 -2 89 30 8 15 31 43 51 62 35 34 38 35 118 35 77 0 85 2 126 33 24 17 52 32 61 32 9 0 42 18 73 40 30 22 61 40 69 40 21 0 88 -26 100 -38 7 -7 17 -12 24 -12 7 0 35 -11 62 -25 66 -33 263 -84 387 -101 189 -25 372 -12 574 41 106 27 130 37 261 97 41 20 80 37 85 39 6 2 51 31 100 64 166 111 405 372 489 534 10 20 22 43 27 51 5 8 12 22 15 30 3 8 17 40 31 70 54 115 95 313 108 520 13 200 -43 480 -134 672 -28 58 -51 108 -51 112 0 3 -13 24 -29 48 -15 24 -34 60 -40 80 -19 57 3 142 50 193 10 11 22 49 28 85 6 36 16 67 21 68 18 6 31 53 25 83 -4 18 -17 33 -36 41 -16 7 -29 15 -29 18 1 10 38 50 47 50 5 0 20 11 33 25 18 19 22 31 17 61 -3 20 -14 45 -23 55 -16 18 -16 20 6 44 15 16 21 32 18 49 -3 15 1 34 8 43 32 43 7 73 -46 55 l-30 -11 0 85 c0 74 -2 84 -18 84 -21 0 -53 -33 -103 -104 l-34 -48 -5 74 c-7 102 -35 133 -80 88z m-870 -740 c36 -7 75 -14 88 -16 21 -4 23 -9 16 -37 -3 -18 -14 -43 -24 -57 -10 -14 -20 -35 -24 -46 -4 -12 -16 -32 -27 -45 -12 -13 -37 -49 -56 -79 -20 -30 -52 -73 -72 -96 -53 -60 -114 -133 -156 -189 -21 -27 -44 -54 -52 -58 -7 -4 -13 -14 -13 -22 0 -7 -18 -33 -40 -57 -22 -23 -40 -46 -40 -50 0 -5 -19 -21 -42 -38 -47 -35 -85 -38 -188 -15 -115 25 -173 20 -264 -23 -45 -22 -106 -46 -136 -56 -48 -15 -77 -25 -140 -50 -70 -28 -100 -77 -51 -84 14 -2 34 -10 45 -17 12 -7 53 -16 91 -20 90 -9 131 -22 178 -57 20 -16 52 -35 70 -43 18 -7 40 -22 49 -32 16 -18 15 -22 -24 -88 -23 -39 -47 -74 -53 -80 -7 -5 -23 -26 -36 -45 -26 -39 -92 -113 -207 -232 -4 -4 -37 -36 -73 -71 l-66 -64 -20 41 c-58 119 -105 240 -115 301 -40 244 -35 409 20 595 8 30 21 66 28 80 7 14 24 54 38 89 15 35 35 75 46 89 11 13 20 31 20 38 0 8 3 14 8 14 4 0 16 16 27 36 24 45 221 245 278 281 23 15 44 30 47 33 20 20 138 78 250 123 61 24 167 50 250 61 60 7 302 -1 370 -14z m837 -661 c52 -101 102 -279 106 -379 2 -42 0 -45 -28 -51 -16 -4 -101 -7 -187 -8 -166 -1 -229 10 -271 49 -19 19 -19 19 14 49 22 21 44 31 65 31 41 0 84 34 84 66 0 30 12 55 56 112 19 25 37 65 44 95 11 51 53 111 74 104 6 -2 25 -32 43 -68z m-662 -810 c17 -10 40 -24 53 -30 12 -7 22 -16 22 -20 0 -4 17 -13 38 -19 20 -7 44 -18 52 -24 8 -7 33 -21 55 -31 22 -11 42 -23 45 -26 11 -14 109 -49 164 -58 62 -11 101 -7 126 14 15 14 38 18 78 16 39 -2 26 -41 -49 -146 -78 -109 -85 -118 -186 -219 -61 -61 -239 -189 -281 -203 -17 -5 -73 -29 -104 -44 -187 -92 -605 -103 -791 -21 -42 19 -47 24 -37 41 5 11 28 32 51 48 22 15 51 38 64 51 13 12 28 22 33 22 17 0 242 233 242 250 0 6 5 10 10 10 6 0 10 6 10 14 0 25 50 55 100 62 59 8 56 6 115 83 50 66 74 117 75 162 0 14 7 40 16 57 18 38 52 41 99 11z"></path>
|
||||
</g>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<!-- 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
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Ask AI Panel -->
|
||||
<div class="ask-ai-panel" id="askAiPanel" style="display: none;">
|
||||
<div class="ask-ai-content">
|
||||
<div id="authCheck" class="auth-check">
|
||||
<div class="loading-content">
|
||||
<div class="loading-spinner"></div>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="chatForm" class="ask-ai-form" style="display: none;">
|
||||
<input type="text" id="aiQuestion" placeholder="What would you like to know?" />
|
||||
<button onclick="askQuestion()" id="askButton">Ask</button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
{% block sidebar %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<footer class="main-footer">
|
||||
<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://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/layouts/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 %}
|
45
my-blog/layouts/index.html
Normal file
@@ -0,0 +1,45 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="timeline-container">
|
||||
|
||||
<div class="timeline-feed">
|
||||
{% for post in posts %}
|
||||
<article class="timeline-post">
|
||||
<div class="post-header">
|
||||
<div class="post-meta">
|
||||
<time class="post-date">{{ post.date }}</time>
|
||||
{% if post.language %}
|
||||
<span class="post-lang">{{ post.language }}</span>
|
||||
{% endif %}
|
||||
{% if post.type == "ai" %}
|
||||
<span class="post-ai">
|
||||
<span class="ai-icon icon-ai"></span>
|
||||
ai
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="post-content">
|
||||
<h3 class="post-title">
|
||||
<a href="{{ post.url }}">{{ post.title }}</a>
|
||||
</h3>
|
||||
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- OAuth Comment System -->
|
||||
<section class="comment-section">
|
||||
<div id="comment-atproto"></div>
|
||||
</section>
|
||||
|
||||
{% if posts|length == 0 %}
|
||||
<div class="empty-state">
|
||||
<p>No posts yet. Start writing!</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
3
my-blog/layouts/oauth-assets.html
Normal file
@@ -0,0 +1,3 @@
|
||||
<!-- OAuth Comment System - Load globally for session management -->
|
||||
<script type="module" crossorigin src="/assets/comment-atproto-93YR1Hl3.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/comment-atproto-rDV6HevJ.css">
|
71
my-blog/layouts/partials/oauth-widget.html
Normal file
@@ -0,0 +1,71 @@
|
||||
<!-- OAuth authentication widget for ailog -->
|
||||
<div id="oauth-widget">
|
||||
<div id="status" class="status">
|
||||
Login with your Bluesky account
|
||||
</div>
|
||||
|
||||
<!-- Login form -->
|
||||
<div id="login-form">
|
||||
<input type="text" id="handle-input" placeholder="Enter your handle (e.g., user.bsky.social)" style="width: 300px; padding: 10px; margin: 10px 0; border: 1px solid #ddd; border-radius: 4px;">
|
||||
<br>
|
||||
<button id="login-btn">🦋 Login with Bluesky</button>
|
||||
</div>
|
||||
|
||||
<!-- Authenticated state -->
|
||||
<div id="authenticated-state" style="display: none;">
|
||||
<div id="user-info"></div>
|
||||
<button id="logout-btn">Logout</button>
|
||||
<button id="test-profile-btn">Get Profile</button>
|
||||
</div>
|
||||
|
||||
<div id="console-log" class="log"></div>
|
||||
</div>
|
||||
|
||||
<script src="/oauth-widget-simple.js"></script>
|
||||
|
||||
<style>
|
||||
.status {
|
||||
margin: 20px 0;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.user-info {
|
||||
background: #e8f5e8;
|
||||
border: 1px solid #4caf50;
|
||||
}
|
||||
.error {
|
||||
background: #ffeaea;
|
||||
border: 1px solid #f44336;
|
||||
color: #d32f2f;
|
||||
}
|
||||
#oauth-widget button {
|
||||
background: #1185fe;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 6px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
margin: 10px;
|
||||
}
|
||||
#oauth-widget button:hover {
|
||||
background: #0d6efd;
|
||||
}
|
||||
#oauth-widget button:disabled {
|
||||
background: #6c757d;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.log {
|
||||
text-align: left;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 6px;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
48
my-blog/layouts/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/layouts/pds.html
Normal file
@@ -0,0 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}at-uri browser - {{ config.title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% endblock %}
|
373
my-blog/layouts/post-complex.html
Normal file
@@ -0,0 +1,373 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ post.title }} - {{ config.title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="article-container">
|
||||
<article class="article-content">
|
||||
<header class="article-header">
|
||||
<h1 class="article-title">{{ post.title }}</h1>
|
||||
<div class="article-meta">
|
||||
<time class="article-date">{{ post.date }}</time>
|
||||
{% if post.language %}
|
||||
<span class="article-lang">{{ post.language }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="article-actions">
|
||||
{% if post.markdown_url %}
|
||||
<a href="{{ post.markdown_url }}" class="action-btn markdown-btn" title="View Markdown">
|
||||
.md
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if post.translation_url %}
|
||||
<a href="{{ post.translation_url }}" class="action-btn translation-btn" title="View Translation">
|
||||
🌐 {% if post.language == 'ja' %}English{% else %}日本語{% endif %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="article-body">
|
||||
{{ post.content | safe }}
|
||||
</div>
|
||||
|
||||
<!-- Comment Section -->
|
||||
<section class="comment-section">
|
||||
<div class="comment-container">
|
||||
<h3>Comments</h3>
|
||||
|
||||
<!-- ATProto Auth Widget Container -->
|
||||
<div id="atproto-auth-widget" class="comment-auth"></div>
|
||||
|
||||
<div id="commentForm" class="comment-form" style="display: none;">
|
||||
<textarea id="commentText" placeholder="Share your thoughts..." rows="4"></textarea>
|
||||
<button onclick="submitComment()" class="submit-btn">Post Comment</button>
|
||||
</div>
|
||||
|
||||
<div id="commentsList" class="comments-list">
|
||||
<!-- Comments will be loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
|
||||
<aside class="article-sidebar">
|
||||
<nav class="toc">
|
||||
<h3>Contents</h3>
|
||||
<div id="toc-content">
|
||||
<!-- TOC will be generated by JavaScript -->
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar %}
|
||||
<!-- Include ATProto Libraries via script tags (more reliable than dynamic imports) -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/@atproto/oauth-client-browser@latest/dist/index.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@atproto/api@latest/dist/index.js"></script>
|
||||
|
||||
<!-- Fallback: Try multiple CDNs -->
|
||||
<script>
|
||||
console.log('Checking ATProto library availability...');
|
||||
|
||||
// Check if libraries loaded successfully
|
||||
if (typeof ATProto === 'undefined' && typeof window.ATProto === 'undefined') {
|
||||
console.log('Primary CDN failed, trying fallback...');
|
||||
|
||||
// Create fallback script elements
|
||||
const fallbackScripts = [
|
||||
'https://unpkg.com/@atproto/oauth-client-browser@latest/dist/index.js',
|
||||
'https://esm.sh/@atproto/oauth-client-browser',
|
||||
'https://cdn.skypack.dev/@atproto/oauth-client-browser'
|
||||
];
|
||||
|
||||
// Load fallback scripts sequentially
|
||||
let scriptIndex = 0;
|
||||
function loadNextScript() {
|
||||
if (scriptIndex < fallbackScripts.length) {
|
||||
const script = document.createElement('script');
|
||||
script.src = fallbackScripts[scriptIndex];
|
||||
script.onload = () => {
|
||||
console.log(`Loaded from fallback CDN: ${fallbackScripts[scriptIndex]}`);
|
||||
window.atprotoLibrariesReady = true;
|
||||
};
|
||||
script.onerror = () => {
|
||||
console.log(`Failed to load from: ${fallbackScripts[scriptIndex]}`);
|
||||
scriptIndex++;
|
||||
loadNextScript();
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
} else {
|
||||
console.error('All CDN fallbacks failed');
|
||||
window.atprotoLibrariesReady = false;
|
||||
}
|
||||
}
|
||||
|
||||
loadNextScript();
|
||||
} else {
|
||||
console.log('✅ ATProto libraries loaded from primary CDN');
|
||||
window.atprotoLibrariesReady = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Simple ATProto Widget (no external dependency) -->
|
||||
<link rel="stylesheet" href="/atproto-auth-widget/dist/atproto-auth.min.css">
|
||||
|
||||
<script>
|
||||
// Initialize auth widget
|
||||
let authWidget = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
generateTableOfContents();
|
||||
initializeAuthWidget();
|
||||
loadComments();
|
||||
});
|
||||
|
||||
function generateTableOfContents() {
|
||||
const tocContainer = document.getElementById('toc-content');
|
||||
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) {
|
||||
tocContainer.innerHTML = '<p class="no-toc">No headings found</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const tocList = document.createElement('ul');
|
||||
tocList.className = 'toc-list';
|
||||
|
||||
headings.forEach((heading, index) => {
|
||||
const id = `heading-${index}`;
|
||||
heading.id = id;
|
||||
|
||||
const listItem = document.createElement('li');
|
||||
listItem.className = `toc-item toc-${heading.tagName.toLowerCase()}`;
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = `#${id}`;
|
||||
link.textContent = heading.textContent;
|
||||
link.className = 'toc-link';
|
||||
|
||||
// Smooth scroll behavior
|
||||
link.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
heading.scrollIntoView({ behavior: 'smooth' });
|
||||
});
|
||||
|
||||
listItem.appendChild(link);
|
||||
tocList.appendChild(listItem);
|
||||
});
|
||||
|
||||
tocContainer.appendChild(tocList);
|
||||
}
|
||||
|
||||
// Initialize ATProto Auth Widget
|
||||
async function initializeAuthWidget() {
|
||||
try {
|
||||
// Check WebCrypto API availability
|
||||
console.log('WebCrypto check:', {
|
||||
available: !!window.crypto && !!window.crypto.subtle,
|
||||
secureContext: window.isSecureContext,
|
||||
protocol: window.location.protocol,
|
||||
hostname: window.location.hostname
|
||||
});
|
||||
|
||||
if (!window.crypto || !window.crypto.subtle) {
|
||||
throw new Error('WebCrypto API is not available. This requires HTTPS or localhost.');
|
||||
}
|
||||
|
||||
if (!window.isSecureContext) {
|
||||
console.warn('Not in secure context - WebCrypto may not work properly');
|
||||
}
|
||||
|
||||
// Simplified approach: Show manual OAuth form
|
||||
console.log('Using simplified OAuth approach...');
|
||||
showSimpleOAuthForm();
|
||||
// Fallback to widget initialization
|
||||
authWidget = await window.initATProtoWidget('#atproto-auth-widget', {
|
||||
clientId: clientId,
|
||||
onLogin: (session) => {
|
||||
console.log('User logged in:', session.handle);
|
||||
document.getElementById('commentForm').style.display = 'block';
|
||||
},
|
||||
onLogout: () => {
|
||||
console.log('User logged out');
|
||||
document.getElementById('commentForm').style.display = 'none';
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('ATProto Auth Error:', error);
|
||||
// Show user-friendly error message
|
||||
const authContainer = document.getElementById('atproto-auth-widget');
|
||||
if (authContainer) {
|
||||
let errorMessage = 'Authentication service is temporarily unavailable.';
|
||||
let suggestion = 'Please try refreshing the page.';
|
||||
|
||||
if (error.message && error.message.includes('WebCrypto')) {
|
||||
errorMessage = 'This feature requires a secure HTTPS connection.';
|
||||
suggestion = 'Please ensure you are accessing via https://log.syui.ai';
|
||||
}
|
||||
|
||||
authContainer.innerHTML = `
|
||||
<div class="atproto-auth__fallback">
|
||||
<p>${errorMessage}</p>
|
||||
<p>${suggestion}</p>
|
||||
<details style="margin-top: 10px; font-size: 0.8em; color: #666;">
|
||||
<summary>Technical details</summary>
|
||||
<pre>${error.message || 'Unknown error'}</pre>
|
||||
</details>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
},
|
||||
theme: 'default'
|
||||
});
|
||||
} else if (typeof window.ATProtoAuthWidget === 'function') {
|
||||
// Fallback to direct widget initialization
|
||||
authWidget = new window.ATProtoAuthWidget({
|
||||
containerSelector: '#atproto-auth-widget',
|
||||
clientId: clientId,
|
||||
onLogin: (session) => {
|
||||
console.log('User logged in:', session.handle);
|
||||
document.getElementById('commentForm').style.display = 'block';
|
||||
},
|
||||
onLogout: () => {
|
||||
console.log('User logged out');
|
||||
document.getElementById('commentForm').style.display = 'none';
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('ATProto Auth Error:', error);
|
||||
const authContainer = document.getElementById('atproto-auth-widget');
|
||||
if (authContainer) {
|
||||
authContainer.innerHTML = `
|
||||
<div class="atproto-auth__fallback">
|
||||
<p>Authentication service is temporarily unavailable.</p>
|
||||
<p>Please try refreshing the page.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
},
|
||||
theme: 'default'
|
||||
});
|
||||
await authWidget.init();
|
||||
} else {
|
||||
throw new Error('ATProto widget not available');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize auth widget:', error);
|
||||
// Show fallback UI
|
||||
const authContainer = document.getElementById('atproto-auth-widget');
|
||||
if (authContainer) {
|
||||
authContainer.innerHTML = `
|
||||
<div class="atproto-auth__fallback">
|
||||
<p>Authentication widget failed to load.</p>
|
||||
<p>Please check your internet connection and refresh the page.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function submitComment() {
|
||||
const commentText = document.getElementById('commentText').value.trim();
|
||||
if (!commentText || !authWidget.isLoggedIn()) {
|
||||
alert('Please login and enter a comment');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const postSlug = '{{ post.slug }}';
|
||||
const postUrl = window.location.href;
|
||||
const createdAt = new Date().toISOString();
|
||||
|
||||
// Create comment record using the auth widget
|
||||
const response = await authWidget.createRecord('ai.log.comment', {
|
||||
$type: 'ai.log.comment',
|
||||
text: commentText,
|
||||
post_slug: postSlug,
|
||||
post_url: postUrl,
|
||||
createdAt: createdAt
|
||||
});
|
||||
|
||||
console.log('Comment posted:', response);
|
||||
document.getElementById('commentText').value = '';
|
||||
loadComments();
|
||||
} catch (error) {
|
||||
console.error('Comment submission failed:', error);
|
||||
alert('Failed to post comment: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function showAuthenticatedState(session) {
|
||||
const authContainer = document.getElementById('atproto-auth-widget');
|
||||
const agent = new window.ATProtoAgent(session);
|
||||
|
||||
authContainer.innerHTML = `
|
||||
<div class="atproto-auth__authenticated">
|
||||
<p>✅ Authenticated as: <strong>${session.did}</strong></p>
|
||||
<button id="logout-btn" class="atproto-auth__button">Logout</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('logout-btn').onclick = async () => {
|
||||
await session.signOut();
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
// Show comment form
|
||||
document.getElementById('commentForm').style.display = 'block';
|
||||
window.currentSession = session;
|
||||
window.currentAgent = agent;
|
||||
}
|
||||
|
||||
function showLoginForm(oauthClient) {
|
||||
const authContainer = document.getElementById('atproto-auth-widget');
|
||||
|
||||
authContainer.innerHTML = `
|
||||
<div class="atproto-auth__login">
|
||||
<h4>Login with ATProto</h4>
|
||||
<input type="text" id="handle-input" placeholder="user.bsky.social" />
|
||||
<button id="login-btn" class="atproto-auth__button">Connect</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.getElementById('login-btn').onclick = async () => {
|
||||
const handle = document.getElementById('handle-input').value.trim();
|
||||
if (!handle) {
|
||||
alert('Please enter your handle');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = await oauthClient.authorize(handle);
|
||||
window.open(url, '_self', 'noopener');
|
||||
} catch (error) {
|
||||
console.error('OAuth authorization failed:', error);
|
||||
alert('Authentication failed: ' + error.message);
|
||||
}
|
||||
};
|
||||
|
||||
// Enter key support
|
||||
document.getElementById('handle-input').onkeypress = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
document.getElementById('login-btn').click();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function loadComments() {
|
||||
try {
|
||||
const commentsList = document.getElementById('commentsList');
|
||||
commentsList.innerHTML = '<p class="loading">Loading comments from ATProto network...</p>';
|
||||
|
||||
// In a real implementation, you would query an aggregation service
|
||||
// For demo, show empty state
|
||||
setTimeout(() => {
|
||||
commentsList.innerHTML = '<p class="no-comments">Comments will appear here when posted via ATProto.</p>';
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
console.error('Failed to load comments:', error);
|
||||
document.getElementById('commentsList').innerHTML = '<p class="error">Failed to load comments</p>';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
196
my-blog/layouts/post-simple.html
Normal file
@@ -0,0 +1,196 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ post.title }} - {{ config.title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="article-container">
|
||||
<article class="article-content">
|
||||
<header class="article-header">
|
||||
<h1 class="article-title">{{ post.title }}</h1>
|
||||
<div class="article-meta">
|
||||
<time class="article-date">{{ post.date }}</time>
|
||||
{% if post.language %}
|
||||
<span class="article-lang">{{ post.language }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="article-actions">
|
||||
{% if post.markdown_url %}
|
||||
<a href="{{ post.markdown_url }}" class="action-btn markdown-btn" title="View Markdown">
|
||||
.md
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if post.translation_url %}
|
||||
<a href="{{ post.translation_url }}" class="action-btn translation-btn" title="View Translation">
|
||||
🌐 {% if post.language == 'ja' %}English{% else %}日本語{% endif %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="article-body">
|
||||
{{ post.content | safe }}
|
||||
</div>
|
||||
|
||||
<!-- Simple Comment Section -->
|
||||
<section class="comment-section">
|
||||
<div class="comment-container">
|
||||
<h3>Comments</h3>
|
||||
|
||||
<!-- Simple OAuth Button -->
|
||||
<div class="simple-oauth">
|
||||
<p>📝 To comment, authenticate with Bluesky:</p>
|
||||
<button id="bluesky-auth" class="oauth-button">
|
||||
🦋 Login with Bluesky
|
||||
</button>
|
||||
<p class="oauth-note">
|
||||
<small>After authentication, you can post comments that will be stored in your ATProto PDS.</small>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="comments-list" class="comments-list">
|
||||
<p class="no-comments">Comments will appear here when posted via ATProto.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
|
||||
<aside class="article-sidebar">
|
||||
<nav class="toc">
|
||||
<h3>Contents</h3>
|
||||
<div id="toc-content">
|
||||
<!-- TOC will be generated by JavaScript -->
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
generateTableOfContents();
|
||||
initializeSimpleAuth();
|
||||
});
|
||||
|
||||
function generateTableOfContents() {
|
||||
const tocContainer = document.getElementById('toc-content');
|
||||
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) {
|
||||
tocContainer.innerHTML = '<p class="no-toc">No headings found</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const tocList = document.createElement('ul');
|
||||
tocList.className = 'toc-list';
|
||||
|
||||
headings.forEach((heading, index) => {
|
||||
const id = `heading-${index}`;
|
||||
heading.id = id;
|
||||
|
||||
const listItem = document.createElement('li');
|
||||
listItem.className = `toc-item toc-${heading.tagName.toLowerCase()}`;
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = `#${id}`;
|
||||
link.textContent = heading.textContent;
|
||||
link.className = 'toc-link';
|
||||
|
||||
link.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
heading.scrollIntoView({ behavior: 'smooth' });
|
||||
});
|
||||
|
||||
listItem.appendChild(link);
|
||||
tocList.appendChild(listItem);
|
||||
});
|
||||
|
||||
tocContainer.appendChild(tocList);
|
||||
}
|
||||
|
||||
function initializeSimpleAuth() {
|
||||
const authButton = document.getElementById('bluesky-auth');
|
||||
|
||||
authButton.addEventListener('click', function() {
|
||||
// Simple approach: Direct redirect to Bluesky OAuth
|
||||
const isProduction = window.location.hostname === 'log.syui.ai';
|
||||
const clientId = isProduction
|
||||
? 'https://log.syui.ai/client-metadata.json'
|
||||
: window.location.origin + '/client-metadata.json';
|
||||
|
||||
const authUrl = `https://bsky.social/oauth/authorize?` +
|
||||
`client_id=${encodeURIComponent(clientId)}&` +
|
||||
`redirect_uri=${encodeURIComponent(window.location.href)}&` +
|
||||
`response_type=code&` +
|
||||
`scope=atproto%20transition:generic&` +
|
||||
`state=demo-state`;
|
||||
|
||||
console.log('Redirecting to:', authUrl);
|
||||
|
||||
// Open in new tab for now (safer for testing)
|
||||
window.open(authUrl, '_blank');
|
||||
|
||||
// Show status message
|
||||
authButton.innerHTML = '✅ Check the new tab for authentication';
|
||||
authButton.disabled = true;
|
||||
});
|
||||
|
||||
// Check if we're returning from OAuth
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.has('code')) {
|
||||
console.log('OAuth callback detected:', urlParams.get('code'));
|
||||
document.querySelector('.simple-oauth').innerHTML = `
|
||||
<div class="oauth-success">
|
||||
✅ OAuth callback received!<br>
|
||||
<small>Code: ${urlParams.get('code')}</small><br>
|
||||
<small>In a full implementation, this would exchange the code for tokens.</small>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.simple-oauth {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.oauth-button {
|
||||
background: #1185fe;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 6px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.oauth-button:hover {
|
||||
background: #0d6efd;
|
||||
}
|
||||
|
||||
.oauth-button:disabled {
|
||||
background: #6c757d;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.oauth-note {
|
||||
color: #6c757d;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.oauth-success {
|
||||
background: #d1edff;
|
||||
border: 1px solid #b6d7ff;
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
color: #0c5460;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
106
my-blog/layouts/post.html
Normal file
@@ -0,0 +1,106 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ post.title }} - {{ config.title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="article-container">
|
||||
<article class="article-content">
|
||||
<header class="article-header">
|
||||
<h1 class="article-title">{{ post.title }}</h1>
|
||||
<div class="article-meta">
|
||||
<time class="article-date">{{ post.date }}</time>
|
||||
{% if post.language %}
|
||||
<span class="article-lang">{{ post.language }}</span>
|
||||
{% endif %}
|
||||
{% if post.extra.type == "ai" %}
|
||||
<span class="article-ai">
|
||||
<span class="ai-icon icon-ai"></span>
|
||||
ai
|
||||
</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">
|
||||
.md
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if post.translation_url %}
|
||||
<a href="{{ post.translation_url }}" class="action-btn translation-btn" title="View Translation">
|
||||
🌐 {% if post.language == 'ja' %}English{% else %}日本語{% endif %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
{% 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>
|
||||
|
||||
<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) {
|
||||
tocContainer.innerHTML = '<p class="no-toc">No headings found</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const tocList = document.createElement('ul');
|
||||
tocList.className = 'toc-list';
|
||||
|
||||
headings.forEach((heading, index) => {
|
||||
const id = `heading-${index}`;
|
||||
heading.id = id;
|
||||
|
||||
const listItem = document.createElement('li');
|
||||
listItem.className = `toc-item toc-${heading.tagName.toLowerCase()}`;
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = `#${id}`;
|
||||
link.textContent = heading.textContent;
|
||||
link.className = 'toc-link';
|
||||
|
||||
link.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
heading.scrollIntoView({ behavior: 'smooth' });
|
||||
});
|
||||
|
||||
listItem.appendChild(link);
|
||||
tocList.appendChild(listItem);
|
||||
});
|
||||
|
||||
tocContainer.appendChild(tocList);
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
generateTableOfContents();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% endblock %}
|
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=qwen3
|
||||
VITE_AI_HOST=http://localhost:11434
|
||||
VITE_AI_SYSTEM_PROMPT="あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"
|
345
my-blog/static/css/pds.css
Normal file
@@ -0,0 +1,345 @@
|
||||
@import url('./style.css');
|
||||
|
||||
.pds-container {
|
||||
}
|
||||
|
||||
.pds-header {
|
||||
text-align: center;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.pds-header h1 {
|
||||
font-size: 2.5em;
|
||||
margin-bottom: 10px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.pds-search-section {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.pds-search-form {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 0px 20px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px 0 0 4px;
|
||||
font-size: 14px;
|
||||
width: 600px;
|
||||
outline: none;
|
||||
transition: box-shadow 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
border-color: var(--theme-color, #f40);
|
||||
}
|
||||
|
||||
.form-group button {
|
||||
padding: 9px 15px;
|
||||
background: #1976d2;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0 4px 4px 0;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group button:hover {
|
||||
background: #1565c0;
|
||||
}
|
||||
|
||||
/*
|
||||
.user-info {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
*/
|
||||
|
||||
.user-profile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.user-details h3 {
|
||||
margin: 0 0 5px 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.user-details p {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.user-did-section {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.did-display {
|
||||
padding: 10px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
word-break: break-all;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.handle-display {
|
||||
padding: 8px 10px;
|
||||
background: #f0f9f0;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
color: #555;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.handle-display strong {
|
||||
color: #2e7d32;
|
||||
}
|
||||
|
||||
.handle-display span {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
|
||||
.pds-display {
|
||||
padding: 8px 10px;
|
||||
background: #e8f4f8;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.pds-display strong {
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.pds-display span {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.collections-section,
|
||||
.records-section {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.collections-section h3,
|
||||
.records-section h3 {
|
||||
font-size: 1.2em;
|
||||
margin-bottom: 15px;
|
||||
color: #333;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.collections-list,
|
||||
.records-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.at-uri-link {
|
||||
display: block;
|
||||
padding: 8px 12px;
|
||||
background: #f9f9f9;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e0e0e0;
|
||||
color: #1976d2;
|
||||
text-decoration: none;
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
word-break: break-all;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.at-uri-link:hover {
|
||||
background: #e8f4f8;
|
||||
border-color: #1976d2;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.pds-info {
|
||||
padding: 8px 12px;
|
||||
background: #f0f9ff;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #b3e5fc;
|
||||
margin-bottom: 8px;
|
||||
color: #1976d2;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.collection-info {
|
||||
padding: 8px 12px;
|
||||
background: #f0f9f0;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #b3e5b3;
|
||||
margin-bottom: 8px;
|
||||
color: #2e7d32;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.collections-header {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.collections-toggle {
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.collections-toggle:hover {
|
||||
background: #e8f4f8;
|
||||
border-color: #1976d2;
|
||||
}
|
||||
|
||||
|
||||
.pds-test-section,
|
||||
.pds-about-section {
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.pds-test-section h2,
|
||||
.pds-about-section h2 {
|
||||
font-size: 1.8em;
|
||||
margin-bottom: 20px;
|
||||
color: #333;
|
||||
border-bottom: 2px solid #1976d2;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.test-uris {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.at-uri {
|
||||
background: #f5f5f5;
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
word-break: break-all;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
border: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.at-uri:hover {
|
||||
background: #e8f4f8;
|
||||
border-color: #1976d2;
|
||||
}
|
||||
|
||||
.pds-about-section ul {
|
||||
list-style-type: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.pds-about-section li {
|
||||
padding: 5px 0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
|
||||
/* AT URI Modal Styles */
|
||||
.at-uri-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.at-uri-modal-content {
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
max-width: 800px;
|
||||
max-height: 600px;
|
||||
width: 90%;
|
||||
height: 80%;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.at-uri-modal-close {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
z-index: 1001;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
/* Loading states */
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.error {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: #d32f2f;
|
||||
background: #ffeaea;
|
||||
border-radius: 4px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.pds-search-section {
|
||||
display: none;
|
||||
}
|
||||
.pds-search-form {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
BIN
my-blog/static/img/aios_v0201.png
Normal file
After Width: | Height: | Size: 260 KiB |
BIN
my-blog/static/img/atproto_oauth_syuis.png
Normal file
After Width: | Height: | Size: 252 KiB |
BIN
my-blog/static/img/bluecheck_ozone_socialapp.png
Normal file
After Width: | Height: | Size: 46 KiB |
BIN
my-blog/static/img/comfyui_flex1_nano_banana_0001.png
Normal file
After Width: | Height: | Size: 1.1 MiB |
BIN
my-blog/static/img/comfyui_wan21_0001.mp4
Normal file
BIN
my-blog/static/img/comfyui_wan21_0001.webp
Normal file
After Width: | Height: | Size: 2.2 MiB |
BIN
my-blog/static/img/comfyui_wan22_0001.mp4
Normal file
BIN
my-blog/static/img/comfyui_wan22_0001.png
Normal file
After Width: | Height: | Size: 520 KiB |
BIN
my-blog/static/img/ue_blender_model_ai_v0401.png
Normal file
After Width: | Height: | Size: 1.5 MiB |
BIN
my-blog/static/img/ue_blender_model_ai_v0402.png
Normal file
After Width: | Height: | Size: 1.5 MiB |
BIN
my-blog/static/img/ue_blender_model_ai_v0501.png
Normal file
After Width: | Height: | Size: 1.6 MiB |
BIN
my-blog/static/img/ue_blender_model_ai_v0502.png
Normal file
After Width: | Height: | Size: 1.6 MiB |
BIN
my-blog/static/img/ue_blender_model_ai_v0601.png
Normal file
After Width: | Height: | Size: 1.9 MiB |
BIN
my-blog/static/img/ue_blender_model_ai_v0602.png
Normal file
After Width: | Height: | Size: 2.0 MiB |
BIN
my-blog/static/img/ue_blender_model_ai_v0603.png
Normal file
After Width: | Height: | Size: 2.0 MiB |
BIN
my-blog/static/img/ue_blender_model_ai_v0604.png
Normal file
After Width: | Height: | Size: 1.2 MiB |
BIN
my-blog/static/img/ue_gasp_dragonik_shin_v0001.png
Normal file
After Width: | Height: | Size: 723 KiB |
BIN
my-blog/static/img/ue_ps5_controller_v0100.jpg
Normal file
After Width: | Height: | Size: 2.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
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Image Comparison Slider
|
||||
* UE5-style before/after image comparison component
|
||||
*/
|
||||
|
||||
class ImageComparison {
|
||||
constructor(container) {
|
||||
this.container = container;
|
||||
this.slider = container.querySelector('.slider');
|
||||
this.beforeImg = container.querySelector('.img-before');
|
||||
this.afterImg = container.querySelector('.img-after');
|
||||
this.sliderThumb = container.querySelector('.slider-thumb');
|
||||
|
||||
this.isDragging = false;
|
||||
this.containerRect = null;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.bindEvents();
|
||||
this.updatePosition(50); // Start at 50%
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
// Mouse events
|
||||
this.slider.addEventListener('input', (e) => {
|
||||
this.updatePosition(e.target.value);
|
||||
});
|
||||
|
||||
this.slider.addEventListener('mousedown', () => {
|
||||
this.isDragging = true;
|
||||
document.body.style.userSelect = 'none';
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', () => {
|
||||
if (this.isDragging) {
|
||||
this.isDragging = false;
|
||||
document.body.style.userSelect = '';
|
||||
}
|
||||
});
|
||||
|
||||
// Touch events for mobile
|
||||
this.slider.addEventListener('touchstart', (e) => {
|
||||
this.isDragging = true;
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
this.slider.addEventListener('touchmove', (e) => {
|
||||
if (this.isDragging) {
|
||||
const touch = e.touches[0];
|
||||
this.containerRect = this.container.getBoundingClientRect();
|
||||
const x = touch.clientX - this.containerRect.left;
|
||||
const percentage = Math.max(0, Math.min(100, (x / this.containerRect.width) * 100));
|
||||
this.slider.value = percentage;
|
||||
this.updatePosition(percentage);
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
this.slider.addEventListener('touchend', () => {
|
||||
this.isDragging = false;
|
||||
});
|
||||
|
||||
// Direct click on container
|
||||
this.container.addEventListener('click', (e) => {
|
||||
if (e.target === this.container || e.target.classList.contains('img-comparison-slider')) {
|
||||
this.containerRect = this.container.getBoundingClientRect();
|
||||
const x = e.clientX - this.containerRect.left;
|
||||
const percentage = Math.max(0, Math.min(100, (x / this.containerRect.width) * 100));
|
||||
this.slider.value = percentage;
|
||||
this.updatePosition(percentage);
|
||||
}
|
||||
});
|
||||
|
||||
// Keyboard support
|
||||
this.slider.addEventListener('keydown', (e) => {
|
||||
let value = parseFloat(this.slider.value);
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft':
|
||||
value = Math.max(0, value - 1);
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
value = Math.min(100, value + 1);
|
||||
break;
|
||||
case 'Home':
|
||||
value = 0;
|
||||
break;
|
||||
case 'End':
|
||||
value = 100;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
this.slider.value = value;
|
||||
this.updatePosition(value);
|
||||
});
|
||||
}
|
||||
|
||||
updatePosition(percentage) {
|
||||
const position = parseFloat(percentage);
|
||||
|
||||
// Update clip-path for before image to show only the left portion
|
||||
this.beforeImg.style.clipPath = `inset(0 ${100 - position}% 0 0)`;
|
||||
|
||||
// Update slider thumb position
|
||||
this.sliderThumb.style.left = `${position}%`;
|
||||
this.sliderThumb.style.transform = `translateX(-50%)`;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-initialize all image comparison components
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const comparisons = document.querySelectorAll('.img-comparison-container');
|
||||
comparisons.forEach(container => {
|
||||
new ImageComparison(container);
|
||||
});
|
||||
});
|
||||
|
||||
// Export for manual initialization
|
||||
window.ImageComparison = ImageComparison;
|
370
my-blog/static/js/pds.js
Normal file
@@ -0,0 +1,370 @@
|
||||
// AT Protocol API functions
|
||||
const AT_PROTOCOL_CONFIG = {
|
||||
primary: {
|
||||
pds: 'https://syu.is',
|
||||
plc: 'https://plc.syu.is',
|
||||
bsky: 'https://bsky.syu.is',
|
||||
web: 'https://web.syu.is'
|
||||
},
|
||||
fallback: {
|
||||
pds: 'https://bsky.social',
|
||||
plc: 'https://plc.directory',
|
||||
bsky: 'https://public.api.bsky.app',
|
||||
web: 'https://bsky.app'
|
||||
}
|
||||
};
|
||||
|
||||
// Search user function
|
||||
async function searchUser() {
|
||||
const handleInput = document.getElementById('handleInput');
|
||||
const userInfo = document.getElementById('userInfo');
|
||||
const collectionsList = document.getElementById('collectionsList');
|
||||
const recordsList = document.getElementById('recordsList');
|
||||
const searchButton = document.getElementById('searchButton');
|
||||
|
||||
const input = handleInput.value.trim();
|
||||
if (!input) {
|
||||
alert('Handle nameまたはAT URIを入力してください');
|
||||
return;
|
||||
}
|
||||
|
||||
searchButton.disabled = true;
|
||||
searchButton.innerHTML = '@';
|
||||
//searchButton.innerHTML = '<i class="fab fa-bluesky"></i>';
|
||||
|
||||
try {
|
||||
// Clear previous results
|
||||
document.getElementById('userDidSection').style.display = 'none';
|
||||
document.getElementById('collectionsSection').style.display = 'none';
|
||||
document.getElementById('recordsSection').style.display = 'none';
|
||||
collectionsList.innerHTML = '';
|
||||
recordsList.innerHTML = '';
|
||||
|
||||
// Check if input is AT URI
|
||||
if (input.startsWith('at://')) {
|
||||
// Parse AT URI to check if it's a full record or just a handle/collection
|
||||
const uriParts = input.replace('at://', '').split('/').filter(part => part.length > 0);
|
||||
|
||||
if (uriParts.length >= 3) {
|
||||
// Full AT URI with rkey - show in modal
|
||||
showAtUriModal(input);
|
||||
return;
|
||||
} else if (uriParts.length === 1) {
|
||||
// Just handle in AT URI format (at://handle) - treat as regular handle
|
||||
const handle = uriParts[0];
|
||||
const userProfile = await resolveUserProfile(handle);
|
||||
|
||||
if (userProfile.success) {
|
||||
displayUserDid(userProfile.data);
|
||||
await loadUserCollections(handle, userProfile.data.did);
|
||||
} else {
|
||||
alert('ユーザーが見つかりません: ' + userProfile.error);
|
||||
}
|
||||
return;
|
||||
} else if (uriParts.length === 2) {
|
||||
// Collection level AT URI - load collection records
|
||||
const [repo, collection] = uriParts;
|
||||
|
||||
try {
|
||||
// First resolve the repo to get handle if it's a DID
|
||||
let handle = repo;
|
||||
if (repo.startsWith('did:')) {
|
||||
// Try to resolve DID to handle - for now just use the DID
|
||||
handle = repo;
|
||||
}
|
||||
|
||||
loadCollectionRecords(handle, collection, repo);
|
||||
} catch (error) {
|
||||
alert('コレクションの読み込みに失敗しました: ' + error.message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle regular handle search
|
||||
const userProfile = await resolveUserProfile(input);
|
||||
|
||||
if (userProfile.success) {
|
||||
displayUserDid(userProfile.data);
|
||||
await loadUserCollections(input, userProfile.data.did);
|
||||
} else {
|
||||
alert('ユーザーが見つかりません: ' + userProfile.error);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('エラーが発生しました: ' + error.message);
|
||||
} finally {
|
||||
searchButton.disabled = false;
|
||||
searchButton.innerHTML = '@';
|
||||
//searchButton.innerHTML = '<i class="fab fa-bluesky"></i>';
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve user profile
|
||||
async function resolveUserProfile(handle) {
|
||||
try {
|
||||
let response = null;
|
||||
|
||||
// Try syu.is first
|
||||
try {
|
||||
response = await fetch(`${AT_PROTOCOL_CONFIG.primary.pds}/xrpc/com.atproto.repo.describeRepo?repo=${handle}`);
|
||||
} catch (error) {
|
||||
console.log('Failed to resolve from syu.is:', error);
|
||||
}
|
||||
|
||||
// If syu.is fails, try bsky.social
|
||||
if (!response || !response.ok) {
|
||||
response = await fetch(`${AT_PROTOCOL_CONFIG.fallback.pds}/xrpc/com.atproto.repo.describeRepo?repo=${handle}`);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to resolve handle');
|
||||
}
|
||||
|
||||
const repoData = await response.json();
|
||||
|
||||
// Get profile data
|
||||
const profileResponse = await fetch(`${AT_PROTOCOL_CONFIG.fallback.bsky}/xrpc/app.bsky.actor.getProfile?actor=${repoData.did}`);
|
||||
const profileData = await profileResponse.json();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
did: repoData.did,
|
||||
handle: profileData.handle,
|
||||
displayName: profileData.displayName,
|
||||
avatar: profileData.avatar,
|
||||
description: profileData.description,
|
||||
pds: repoData.didDoc.service.find(s => s.type === 'AtprotoPersonalDataServer')?.serviceEndpoint
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Display user DID
|
||||
function displayUserDid(profile) {
|
||||
document.getElementById('userPdsText').textContent = profile.pds || 'Unknown';
|
||||
document.getElementById('userHandleText').textContent = profile.handle;
|
||||
document.getElementById('userDidText').textContent = profile.did;
|
||||
document.getElementById('userDidSection').style.display = 'block';
|
||||
}
|
||||
|
||||
// Load user collections
|
||||
async function loadUserCollections(handle, did) {
|
||||
const collectionsList = document.getElementById('collectionsList');
|
||||
|
||||
collectionsList.innerHTML = '<div class="loading">コレクションを読み込み中...</div>';
|
||||
|
||||
try {
|
||||
// Try to get collections from describeRepo
|
||||
let response = await fetch(`${AT_PROTOCOL_CONFIG.primary.pds}/xrpc/com.atproto.repo.describeRepo?repo=${handle}`);
|
||||
let usedPds = AT_PROTOCOL_CONFIG.primary.pds;
|
||||
|
||||
// If syu.is fails, try bsky.social
|
||||
if (!response.ok) {
|
||||
response = await fetch(`${AT_PROTOCOL_CONFIG.fallback.pds}/xrpc/com.atproto.repo.describeRepo?repo=${handle}`);
|
||||
usedPds = AT_PROTOCOL_CONFIG.fallback.pds;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to describe repository');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const collections = data.collections || [];
|
||||
|
||||
// Display collections as AT URI links
|
||||
collectionsList.innerHTML = '';
|
||||
if (collections.length === 0) {
|
||||
collectionsList.innerHTML = '<div class="error">コレクションが見つかりませんでした</div>';
|
||||
} else {
|
||||
|
||||
collections.forEach(collection => {
|
||||
const atUri = `at://${did}/${collection}/`;
|
||||
const collectionElement = document.createElement('a');
|
||||
collectionElement.className = 'at-uri-link';
|
||||
collectionElement.href = '#';
|
||||
collectionElement.textContent = atUri;
|
||||
collectionElement.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
loadCollectionRecords(handle, collection, did);
|
||||
// Close collections and update toggle
|
||||
document.getElementById('collectionsList').style.display = 'none';
|
||||
document.getElementById('collectionsToggle').textContent = '[-] Collections';
|
||||
};
|
||||
collectionsList.appendChild(collectionElement);
|
||||
});
|
||||
|
||||
document.getElementById('collectionsSection').style.display = 'block';
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
collectionsList.innerHTML = '<div class="error">コレクションの読み込みに失敗しました: ' + error.message + '</div>';
|
||||
document.getElementById('collectionsSection').style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// Load collection records
|
||||
async function loadCollectionRecords(handle, collection, did) {
|
||||
const recordsList = document.getElementById('recordsList');
|
||||
|
||||
recordsList.innerHTML = '<div class="loading">レコードを読み込み中...</div>';
|
||||
|
||||
try {
|
||||
// Try with syu.is first
|
||||
let response = await fetch(`${AT_PROTOCOL_CONFIG.primary.pds}/xrpc/com.atproto.repo.listRecords?repo=${handle}&collection=${collection}`);
|
||||
let usedPds = AT_PROTOCOL_CONFIG.primary.pds;
|
||||
|
||||
// If that fails, try with bsky.social
|
||||
if (!response.ok) {
|
||||
response = await fetch(`${AT_PROTOCOL_CONFIG.fallback.pds}/xrpc/com.atproto.repo.listRecords?repo=${handle}&collection=${collection}`);
|
||||
usedPds = AT_PROTOCOL_CONFIG.fallback.pds;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load records');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Display records as AT URI links
|
||||
recordsList.innerHTML = '';
|
||||
|
||||
// Add collection info for records
|
||||
const collectionInfo = document.createElement('div');
|
||||
collectionInfo.className = 'collection-info';
|
||||
collectionInfo.innerHTML = `<strong>${collection}</strong>`;
|
||||
recordsList.appendChild(collectionInfo);
|
||||
|
||||
data.records.forEach(record => {
|
||||
const atUri = record.uri;
|
||||
const recordElement = document.createElement('a');
|
||||
recordElement.className = 'at-uri-link';
|
||||
recordElement.href = '#';
|
||||
recordElement.textContent = atUri;
|
||||
recordElement.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
showAtUriModal(atUri);
|
||||
};
|
||||
recordsList.appendChild(recordElement);
|
||||
});
|
||||
|
||||
document.getElementById('recordsSection').style.display = 'block';
|
||||
|
||||
} catch (error) {
|
||||
recordsList.innerHTML = '<div class="error">レコードの読み込みに失敗しました: ' + error.message + '</div>';
|
||||
document.getElementById('recordsSection').style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// Show AT URI modal
|
||||
function showAtUriModal(uri) {
|
||||
const modal = document.getElementById('atUriModal');
|
||||
const content = document.getElementById('atUriContent');
|
||||
|
||||
content.innerHTML = '<div class="loading">レコードを読み込み中...</div>';
|
||||
modal.style.display = 'flex';
|
||||
|
||||
// Load record data
|
||||
loadAtUriRecord(uri, content);
|
||||
}
|
||||
|
||||
// Load AT URI record
|
||||
async function loadAtUriRecord(uri, contentElement) {
|
||||
try {
|
||||
const parts = uri.replace('at://', '').split('/');
|
||||
const repo = parts[0];
|
||||
const collection = parts[1];
|
||||
const rkey = parts[2];
|
||||
|
||||
// Try with syu.is first
|
||||
let response = await fetch(`${AT_PROTOCOL_CONFIG.primary.pds}/xrpc/com.atproto.repo.getRecord?repo=${repo}&collection=${collection}&rkey=${rkey}`);
|
||||
|
||||
// If that fails, try with bsky.social
|
||||
if (!response.ok) {
|
||||
response = await fetch(`${AT_PROTOCOL_CONFIG.fallback.pds}/xrpc/com.atproto.repo.getRecord?repo=${repo}&collection=${collection}&rkey=${rkey}`);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load record');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
contentElement.innerHTML = `
|
||||
<div style="padding: 20px;">
|
||||
<h3>AT URI Record</h3>
|
||||
<div style="font-family: monospace; font-size: 14px; color: #666; margin-bottom: 20px; word-break: break-all;">
|
||||
${uri}
|
||||
</div>
|
||||
<div style="font-size: 12px; color: #999; margin-bottom: 20px;">
|
||||
Repo: ${repo} | Collection: ${collection} | RKey: ${rkey}
|
||||
</div>
|
||||
<h4>Record Data</h4>
|
||||
<pre style="background: #f5f5f5; padding: 15px; border-radius: 4px; overflow: auto;">${JSON.stringify(data, null, 2)}</pre>
|
||||
</div>
|
||||
`;
|
||||
} catch (error) {
|
||||
contentElement.innerHTML = `
|
||||
<div style="padding: 20px; color: red;">
|
||||
<strong>Error:</strong> ${error.message}
|
||||
<div style="margin-top: 10px; font-size: 12px;">
|
||||
<strong>URI:</strong> ${uri}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Close AT URI modal
|
||||
function closeAtUriModal(event) {
|
||||
const modal = document.getElementById('atUriModal');
|
||||
if (event && event.target !== modal) {
|
||||
return;
|
||||
}
|
||||
modal.style.display = 'none';
|
||||
}
|
||||
|
||||
// Initialize AT URI click handlers
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Add click handlers to existing AT URIs
|
||||
document.querySelectorAll('.at-uri').forEach(element => {
|
||||
element.addEventListener('click', function() {
|
||||
const uri = this.getAttribute('data-at-uri');
|
||||
showAtUriModal(uri);
|
||||
});
|
||||
});
|
||||
|
||||
// ESC key to close modal
|
||||
document.addEventListener('keydown', function(event) {
|
||||
if (event.key === 'Escape') {
|
||||
closeAtUriModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Enter key to search
|
||||
document.getElementById('handleInput').addEventListener('keydown', function(event) {
|
||||
if (event.key === 'Enter') {
|
||||
searchUser();
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// Toggle collections visibility
|
||||
function toggleCollections() {
|
||||
const collectionsList = document.getElementById('collectionsList');
|
||||
const toggleButton = document.getElementById('collectionsToggle');
|
||||
|
||||
if (collectionsList.style.display === 'none') {
|
||||
collectionsList.style.display = 'block';
|
||||
toggleButton.textContent = '[-] Collections';
|
||||
} else {
|
||||
collectionsList.style.display = 'none';
|
||||
toggleButton.textContent = '[+] Collections';
|
||||
}
|
||||
}
|
@@ -84,7 +84,6 @@ 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)');
|
||||
}
|
||||
}
|
||||
|
||||
|
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
@@ -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;">
|
||||
@@ -74,6 +89,9 @@
|
||||
</div>
|
||||
|
||||
<main class="main-content">
|
||||
<!-- Pds Panel -->
|
||||
{% include "pds-header.html" %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
@@ -84,14 +102,51 @@
|
||||
<div class="footer-social">
|
||||
<a href="https://syu.is/syui" target="_blank"><i class="fab fa-bluesky"></i></a>
|
||||
<a href="https://git.syui.ai/ai" target="_blank"><span class="icon-ai"></span></a>
|
||||
<a href="https://git.syui.ai/syui" target="_blank"><span class="icon-git"></span></a>
|
||||
<a href="https://github.com/syui" target="_blank"><i class="fab fa-github"></i></a>
|
||||
</div>
|
||||
<p>© {{ config.author }}</p>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Config variables from Hugo
|
||||
window.OAUTH_CONFIG = {
|
||||
{% if config.oauth.pds %}
|
||||
pds: "{{ config.oauth.pds }}",
|
||||
{% else %}
|
||||
pds: "syu.is",
|
||||
{% endif %}
|
||||
{% if config.oauth.admin %}
|
||||
admin: "{{ config.oauth.admin }}",
|
||||
{% else %}
|
||||
admin: "ai.syui.ai",
|
||||
{% endif %}
|
||||
{% if config.oauth.collection %}
|
||||
collection: "{{ config.oauth.collection }}"
|
||||
{% else %}
|
||||
collection: "ai.syui.log"
|
||||
{% endif %}
|
||||
};
|
||||
</script>
|
||||
<script src="/js/ask-ai.js"></script>
|
||||
<script src="/js/pds.js"></script>
|
||||
<script src="/js/theme.js"></script>
|
||||
<script src="/js/image-comparison.js"></script>
|
||||
|
||||
<!-- Mermaid support -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/mermaid@10.6.1/dist/mermaid.min.js"></script>
|
||||
<script>
|
||||
mermaid.initialize({
|
||||
startOnLoad: true,
|
||||
theme: 'neutral',
|
||||
securityLevel: 'loose',
|
||||
themeVariables: {
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||
fontSize: '14px'
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{% include "oauth-assets.html" %}
|
||||
{% include "at-browser-assets.html" %}
|
||||
</body>
|
||||
</html>
|
||||
|
135
my-blog/templates/game.html
Normal file
@@ -0,0 +1,135 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Game - {{ config.title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="gameContainer" class="game-container">
|
||||
<div id="gameAuth" class="game-auth-section">
|
||||
<h1>Login to Play</h1>
|
||||
<p>Please authenticate with your AT Protocol account to access the game.</p>
|
||||
<div id="authRoot"></div>
|
||||
</div>
|
||||
<div id="gameFrame" class="game-frame-container" style="display: none;">
|
||||
<iframe
|
||||
id="pixelStreamingFrame"
|
||||
src="https://verse.syui.ai/simple-noui.html"
|
||||
frameborder="0"
|
||||
allowfullscreen
|
||||
allow="microphone; camera; fullscreen; autoplay"
|
||||
class="pixel-streaming-iframe"
|
||||
></iframe>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Game specific styles */
|
||||
.game-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
background: #000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.game-auth-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.game-auth-section h1 {
|
||||
font-size: 2.5em;
|
||||
margin-bottom: 20px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.game-auth-section p {
|
||||
font-size: 1.2em;
|
||||
margin-bottom: 30px;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.game-frame-container {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.pixel-streaming-iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Override auth button for game page */
|
||||
.game-auth-section .auth-section {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.game-auth-section .auth-button {
|
||||
font-size: 1.2em;
|
||||
padding: 12px 30px;
|
||||
}
|
||||
|
||||
/* Hide header and footer on game page */
|
||||
body:has(.game-container) header,
|
||||
body:has(.game-container) footer,
|
||||
body:has(.game-container) nav {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Remove any body padding/margin for full screen game */
|
||||
body:has(.game-container) {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Wait for OAuth component to be loaded
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Check if user is already authenticated
|
||||
const checkAuthStatus = () => {
|
||||
// Check if OAuth components are available and user is authenticated
|
||||
if (window.currentUser && window.currentAgent) {
|
||||
showGame();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Show game iframe
|
||||
const showGame = () => {
|
||||
document.getElementById('gameAuth').style.display = 'none';
|
||||
document.getElementById('gameFrame').style.display = 'block';
|
||||
};
|
||||
|
||||
// Listen for OAuth success
|
||||
window.addEventListener('oauth-success', function(event) {
|
||||
console.log('OAuth success:', event.detail);
|
||||
showGame();
|
||||
});
|
||||
|
||||
// Check auth status on load
|
||||
if (!checkAuthStatus()) {
|
||||
// Check periodically if OAuth components are loaded
|
||||
const authCheckInterval = setInterval(() => {
|
||||
if (checkAuthStatus()) {
|
||||
clearInterval(authCheckInterval);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Include OAuth assets -->
|
||||
{% include "oauth-assets.html" %}
|
||||
{% endblock %}
|
@@ -12,6 +12,12 @@
|
||||
{% if post.language %}
|
||||
<span class="post-lang">{{ post.language }}</span>
|
||||
{% endif %}
|
||||
{% if post.extra and post.extra.type == "ai" %}
|
||||
<span class="post-ai">
|
||||
<span class="ai-icon icon-ai"></span>
|
||||
ai
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
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
@@ -0,0 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}at-uri browser - {{ config.title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% endblock %}
|
@@ -12,7 +12,14 @@
|
||||
{% if post.language %}
|
||||
<span class="article-lang">{{ post.language }}</span>
|
||||
{% endif %}
|
||||
{% if post.extra and post.extra.type == "ai" %}
|
||||
<span class="article-ai">
|
||||
<span class="ai-icon icon-ai"></span>
|
||||
ai
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if not post.extra or 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 +32,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 or 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) {
|
||||
|
@@ -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
|
@@ -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>
|
@@ -1,36 +1,25 @@
|
||||
{
|
||||
"name": "aicard",
|
||||
"version": "0.1.1",
|
||||
"private": true,
|
||||
"name": "ailog-oauth",
|
||||
"version": "0.3.4",
|
||||
"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
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: 100
|
||||
})
|
||||
|
||||
// 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 (oldest first) - for chronological conversation flow
|
||||
chatPairs.sort((a, b) => new Date(a.createdAt) - new Date(b.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>
|
||||
)
|
||||
}
|
1622
oauth/src/App.tsx
310
oauth/src/api/atproto.js
Normal file
@@ -0,0 +1,310 @@
|
||||
// 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)
|
||||
},
|
||||
|
||||
// Find all records for a specific post by paginating through all records
|
||||
async findRecordsForPost(pds, repo, collection, targetRkey) {
|
||||
let cursor = null
|
||||
let allMatchingRecords = []
|
||||
let pageCount = 0
|
||||
const maxPages = 50 // Safety limit to prevent infinite loops
|
||||
|
||||
do {
|
||||
pageCount++
|
||||
if (pageCount > maxPages) {
|
||||
console.warn(`Reached max pages (${maxPages}) while searching for ${targetRkey}`)
|
||||
break
|
||||
}
|
||||
|
||||
const result = await this.getRecords(pds, repo, collection, 100, cursor)
|
||||
|
||||
// Filter records that match the target post
|
||||
const matchingRecords = result.records.filter(record => {
|
||||
const postUrl = record.value?.post?.url
|
||||
if (!postUrl) return false
|
||||
|
||||
try {
|
||||
// Extract rkey from URL
|
||||
const recordRkey = new URL(postUrl).pathname.split('/').pop()?.replace(/\.html$/, '')
|
||||
return recordRkey === targetRkey
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
allMatchingRecords.push(...matchingRecords)
|
||||
cursor = result.cursor
|
||||
|
||||
// Optional: Stop early if we found some records (uncomment if desired)
|
||||
// if (allMatchingRecords.length > 0) break
|
||||
|
||||
} while (cursor)
|
||||
|
||||
console.log(`Found ${allMatchingRecords.length} records for ${targetRkey} after searching ${pageCount} pages`)
|
||||
return allMatchingRecords
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
},
|
||||
|
||||
// Find chat records for a specific post using pagination
|
||||
async getChatForPost(pds, repo, collection, targetRkey) {
|
||||
const cacheKey = dataCache.generateKey('chatForPost', pds, repo, collection, targetRkey)
|
||||
const cached = dataCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
const records = await atproto.findRecordsForPost(pds, repo, `${collection}.chat`, targetRkey)
|
||||
|
||||
// Process into chat pairs like the original getChat function
|
||||
const chatPairs = []
|
||||
const recordMap = new Map()
|
||||
|
||||
// First pass: organize records by base rkey
|
||||
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 (oldest first) - for chronological conversation flow
|
||||
chatPairs.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))
|
||||
|
||||
dataCache.set(cacheKey, chatPairs)
|
||||
return chatPairs
|
||||
},
|
||||
|
||||
// 投稿後にキャッシュを無効化
|
||||
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>
|
||||
);
|
||||
};
|
@@ -57,15 +57,15 @@ export default function AskAI({ adminData, user, agent, onClose }) {
|
||||
<div className="user-message">
|
||||
<div className="message-header">
|
||||
<div className="avatar">
|
||||
{entry.user?.avatar ? (
|
||||
<img src={entry.user.avatar} alt={entry.user.displayName} className="profile-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 || 'You'}</div>
|
||||
<div className="handle">@{entry.user?.handle || 'user'}</div>
|
||||
<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>
|
@@ -1,4 +1,5 @@
|
||||
import React, { useState } from 'react'
|
||||
import { logger } from '../utils/logger.js'
|
||||
|
||||
export default function AuthButton({ user, onLogin, onLogout, loading }) {
|
||||
const [handleInput, setHandleInput] = useState('')
|
||||
@@ -12,7 +13,7 @@ export default function AuthButton({ user, onLogin, onLogout, loading }) {
|
||||
try {
|
||||
await onLogin(handleInput.trim())
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error)
|
||||
logger.error('Login failed:', error)
|
||||
alert('ログインに失敗しました: ' + error.message)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
@@ -25,20 +26,20 @@ export default function AuthButton({ user, onLogin, onLogout, loading }) {
|
||||
|
||||
if (user) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<div className="user-section" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
{user.avatar && (
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt="Profile"
|
||||
className="avatar"
|
||||
className="user-avatar"
|
||||
style={{ width: '24px', height: '24px' }}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<div className="display-name" style={{ fontSize: '14px', fontWeight: '700' }}>
|
||||
<div className="user-display-name" style={{ fontSize: '14px', fontWeight: '700' }}>
|
||||
{user.displayName}
|
||||
</div>
|
||||
<div className="handle" style={{ fontSize: '12px' }}>
|
||||
<div className="user-handle" style={{ fontSize: '12px' }}>
|
||||
@{user.handle}
|
||||
</div>
|
||||
</div>
|
||||
@@ -55,7 +56,7 @@ export default function AuthButton({ user, onLogin, onLogout, loading }) {
|
||||
type="text"
|
||||
value={handleInput}
|
||||
onChange={(e) => setHandleInput(e.target.value)}
|
||||
placeholder="your.handle.com"
|
||||
placeholder="user.bsky.social"
|
||||
disabled={isLoading}
|
||||
className="handle-input"
|
||||
onKeyPress={(e) => {
|
||||
@@ -68,9 +69,9 @@ export default function AuthButton({ user, onLogin, onLogout, loading }) {
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={isLoading || !handleInput.trim()}
|
||||
className="auth-button"
|
||||
className={`auth-button ${isLoading ? 'loading' : ''}`}
|
||||
>
|
||||
{isLoading ? '認証中...' : <i className="fab fa-bluesky"></i>}
|
||||
<i className={isLoading ? "fas fa-spinner" : "fab fa-bluesky"}></i>
|
||||
</button>
|
||||
</div>
|
||||
)
|
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import Avatar, { AvatarWithCard, AvatarList } from './Avatar.jsx'
|
||||
import { getAvatar, batchFetchAvatars, prefetchAvatar } from '../utils/avatar.js'
|
||||
import { logger } from '../utils/logger.js'
|
||||
|
||||
/**
|
||||
* Test component to demonstrate avatar functionality
|
||||
@@ -63,7 +64,7 @@ export default function AvatarTest() {
|
||||
|
||||
setTestResults(results)
|
||||
} catch (error) {
|
||||
console.error('Test failed:', error)
|
||||
logger.error('Test failed:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -78,7 +79,7 @@ export default function AvatarTest() {
|
||||
batchResults: Object.fromEntries(avatarMap)
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('Batch test failed:', error)
|
||||
logger.error('Batch test failed:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -94,7 +95,7 @@ export default function AvatarTest() {
|
||||
prefetchResult: cachedAvatar
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('Prefetch test failed:', error)
|
||||
logger.error('Prefetch test failed:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|