Compare commits
9 Commits
test-oauth
...
292c5f5af0
Author | SHA1 | Date | |
---|---|---|---|
292c5f5af0
|
|||
af5968bd34
|
|||
724b4776e8
|
|||
cea12b1e13
|
|||
ba82543c06
|
|||
efc73490d1
|
|||
1eda00f2d3
|
|||
ac4c3f2ad3
|
|||
2e0fe0edfc
|
@@ -37,22 +37,7 @@
|
||||
"Bash(rg:*)",
|
||||
"Bash(../target/release/ailog build)",
|
||||
"Bash(zsh run.zsh:*)",
|
||||
"Bash(hugo:*)",
|
||||
"WebFetch(domain:docs.bsky.app)",
|
||||
"WebFetch(domain:syui.ai)",
|
||||
"Bash(rustup target list:*)",
|
||||
"Bash(rustup target:*)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(git push:*)",
|
||||
"Bash(git tag:*)",
|
||||
"Bash(../bin/ailog:*)",
|
||||
"Bash(../target/release/ailog oauth build:*)",
|
||||
"Bash(ailog:*)",
|
||||
"WebFetch(domain:plc.directory)",
|
||||
"WebFetch(domain:atproto.com)",
|
||||
"WebFetch(domain:syu.is)",
|
||||
"Bash(sed:*)"
|
||||
"Bash(hugo:*)"
|
||||
],
|
||||
"deny": []
|
||||
}
|
||||
|
@@ -1,53 +0,0 @@
|
||||
name: Deploy to Cloudflare Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Rust
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
override: true
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Build ailog
|
||||
run: |
|
||||
cargo build --release
|
||||
|
||||
- name: Build OAuth app
|
||||
run: |
|
||||
cd oauth
|
||||
npm install
|
||||
npm run build
|
||||
|
||||
- name: Copy OAuth assets
|
||||
run: |
|
||||
cp -r oauth/dist/* my-blog/static/
|
||||
|
||||
- name: Generate site with ailog
|
||||
run: |
|
||||
./target/release/ailog generate --input content --output my-blog/public
|
||||
|
||||
- name: Deploy to Cloudflare Pages
|
||||
uses: cloudflare/pages-action@v1
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
projectName: syui-ai
|
||||
directory: my-blog/public
|
||||
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
|
@@ -1,28 +0,0 @@
|
||||
name: Example ailog usage
|
||||
|
||||
on:
|
||||
workflow_dispatch: # Manual trigger for testing
|
||||
|
||||
jobs:
|
||||
build-with-ailog-action:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build with ailog action
|
||||
uses: ai/log@v1 # This will reference this repository
|
||||
with:
|
||||
content-dir: 'content'
|
||||
output-dir: 'public'
|
||||
ai-integration: true
|
||||
atproto-integration: true
|
||||
|
||||
- name: Deploy to Cloudflare Pages
|
||||
uses: cloudflare/pages-action@v1
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
projectName: my-blog
|
||||
directory: public
|
65
.github/workflows/cloudflare-pages.yml
vendored
65
.github/workflows/cloudflare-pages.yml
vendored
@@ -34,67 +34,22 @@ jobs:
|
||||
|
||||
- name: Copy OAuth build to static
|
||||
run: |
|
||||
# Remove old assets (following run.zsh pattern)
|
||||
rm -rf my-blog/static/assets
|
||||
# Copy all dist files to static
|
||||
cp -rf oauth/dist/* my-blog/static/
|
||||
# Copy index.html to oauth-assets.html template
|
||||
cp oauth/dist/index.html my-blog/templates/oauth-assets.html
|
||||
mkdir -p my-blog/static/assets
|
||||
cp -r oauth/dist/assets/* my-blog/static/assets/
|
||||
cp oauth/dist/index.html my-blog/static/oauth/index.html || true
|
||||
|
||||
- name: Cache ailog binary
|
||||
uses: actions/cache@v4
|
||||
- name: Setup Rust
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
path: ./bin
|
||||
key: ailog-bin-${{ runner.os }}
|
||||
restore-keys: |
|
||||
ailog-bin-${{ runner.os }}
|
||||
|
||||
- name: Setup ailog binary
|
||||
run: |
|
||||
# Get expected version from Cargo.toml
|
||||
EXPECTED_VERSION=$(grep '^version' Cargo.toml | cut -d'"' -f2)
|
||||
echo "Expected version from Cargo.toml: $EXPECTED_VERSION"
|
||||
|
||||
# Check current binary version if exists
|
||||
if [ -f "./bin/ailog" ]; then
|
||||
CURRENT_VERSION=$(./bin/ailog --version 2>/dev/null || echo "unknown")
|
||||
echo "Current binary version: $CURRENT_VERSION"
|
||||
else
|
||||
CURRENT_VERSION="none"
|
||||
echo "No binary found"
|
||||
fi
|
||||
|
||||
# Check OS
|
||||
OS="${{ runner.os }}"
|
||||
echo "Runner OS: $OS"
|
||||
|
||||
# Use pre-packaged binary if version matches or extract from tar.gz
|
||||
if [ "$CURRENT_VERSION" = "$EXPECTED_VERSION" ]; then
|
||||
echo "Binary is up to date"
|
||||
chmod +x ./bin/ailog
|
||||
elif [ "$OS" = "Linux" ] && [ -f "./bin/ailog-linux-x86_64.tar.gz" ]; then
|
||||
echo "Extracting ailog from pre-packaged tar.gz..."
|
||||
cd bin
|
||||
tar -xzf ailog-linux-x86_64.tar.gz
|
||||
chmod +x ailog
|
||||
cd ..
|
||||
|
||||
# Verify extracted version
|
||||
EXTRACTED_VERSION=$(./bin/ailog --version 2>/dev/null || echo "unknown")
|
||||
echo "Extracted binary version: $EXTRACTED_VERSION"
|
||||
|
||||
if [ "$EXTRACTED_VERSION" != "$EXPECTED_VERSION" ]; then
|
||||
echo "Warning: Binary version mismatch. Expected $EXPECTED_VERSION but got $EXTRACTED_VERSION"
|
||||
fi
|
||||
else
|
||||
echo "Error: No suitable binary found for OS: $OS"
|
||||
exit 1
|
||||
fi
|
||||
toolchain: stable
|
||||
|
||||
- name: Build ailog
|
||||
run: cargo build --release
|
||||
|
||||
- name: Build site with ailog
|
||||
run: |
|
||||
cd my-blog
|
||||
../bin/ailog build
|
||||
../target/release/ailog build
|
||||
|
||||
- name: List public directory
|
||||
run: |
|
||||
|
92
.github/workflows/disabled/gh-pages-fast.yml
vendored
92
.github/workflows/disabled/gh-pages-fast.yml
vendored
@@ -1,92 +0,0 @@
|
||||
name: github pages (fast)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- 'src/**'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
|
||||
jobs:
|
||||
build-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pages: write
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Cache ailog binary
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ./bin
|
||||
key: ailog-bin-${{ runner.os }}
|
||||
restore-keys: |
|
||||
ailog-bin-${{ runner.os }}
|
||||
|
||||
- name: Setup ailog binary
|
||||
run: |
|
||||
# Get expected version from Cargo.toml
|
||||
EXPECTED_VERSION=$(grep '^version' Cargo.toml | cut -d'"' -f2)
|
||||
echo "Expected version from Cargo.toml: $EXPECTED_VERSION"
|
||||
|
||||
# Check current binary version if exists
|
||||
if [ -f "./bin/ailog" ]; then
|
||||
CURRENT_VERSION=$(./bin/ailog --version 2>/dev/null || echo "unknown")
|
||||
echo "Current binary version: $CURRENT_VERSION"
|
||||
else
|
||||
CURRENT_VERSION="none"
|
||||
echo "No binary found"
|
||||
fi
|
||||
|
||||
# Check OS
|
||||
OS="${{ runner.os }}"
|
||||
echo "Runner OS: $OS"
|
||||
|
||||
# Use pre-packaged binary if version matches or extract from tar.gz
|
||||
if [ "$CURRENT_VERSION" = "$EXPECTED_VERSION" ]; then
|
||||
echo "Binary is up to date"
|
||||
chmod +x ./bin/ailog
|
||||
elif [ "$OS" = "Linux" ] && [ -f "./bin/ailog-linux-x86_64.tar.gz" ]; then
|
||||
echo "Extracting ailog from pre-packaged tar.gz..."
|
||||
cd bin
|
||||
tar -xzf ailog-linux-x86_64.tar.gz
|
||||
chmod +x ailog
|
||||
cd ..
|
||||
|
||||
# Verify extracted version
|
||||
EXTRACTED_VERSION=$(./bin/ailog --version 2>/dev/null || echo "unknown")
|
||||
echo "Extracted binary version: $EXTRACTED_VERSION"
|
||||
|
||||
if [ "$EXTRACTED_VERSION" != "$EXPECTED_VERSION" ]; then
|
||||
echo "Warning: Binary version mismatch. Expected $EXPECTED_VERSION but got $EXTRACTED_VERSION"
|
||||
fi
|
||||
else
|
||||
echo "Error: No suitable binary found for OS: $OS"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Setup Hugo
|
||||
uses: peaceiris/actions-hugo@v3
|
||||
with:
|
||||
hugo-version: "0.139.2"
|
||||
extended: true
|
||||
|
||||
- name: Build with ailog
|
||||
env:
|
||||
TZ: "Asia/Tokyo"
|
||||
run: |
|
||||
# Use pre-built ailog binary instead of cargo build
|
||||
cd my-blog
|
||||
../bin/ailog build
|
||||
touch ./public/.nojekyll
|
||||
|
||||
- name: Deploy
|
||||
uses: peaceiris/actions-gh-pages@v3
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./my-blog/public
|
||||
publish_branch: gh-pages
|
169
.github/workflows/release.yml
vendored
169
.github/workflows/release.yml
vendored
@@ -1,169 +0,0 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag (e.g., v1.0.0)'
|
||||
required: true
|
||||
default: 'v0.1.0'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
OPENSSL_STATIC: true
|
||||
OPENSSL_VENDOR: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build ${{ matrix.target }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- target: x86_64-unknown-linux-gnu
|
||||
os: ubuntu-latest
|
||||
artifact_name: ailog
|
||||
asset_name: ailog-linux-x86_64
|
||||
- target: aarch64-unknown-linux-gnu
|
||||
os: ubuntu-latest
|
||||
artifact_name: ailog
|
||||
asset_name: ailog-linux-aarch64
|
||||
- target: x86_64-apple-darwin
|
||||
os: macos-latest
|
||||
artifact_name: ailog
|
||||
asset_name: ailog-macos-x86_64
|
||||
- target: aarch64-apple-darwin
|
||||
os: macos-latest
|
||||
artifact_name: ailog
|
||||
asset_name: ailog-macos-aarch64
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Install cross-compilation tools (Linux)
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.target == 'aarch64-unknown-linux-gnu'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gcc-aarch64-linux-gnu binutils-aarch64-linux-gnu
|
||||
|
||||
- name: Configure cross-compilation (Linux ARM64)
|
||||
if: matrix.target == 'aarch64-unknown-linux-gnu'
|
||||
run: |
|
||||
echo '[target.aarch64-unknown-linux-gnu]' >> ~/.cargo/config.toml
|
||||
echo 'linker = "aarch64-linux-gnu-gcc"' >> ~/.cargo/config.toml
|
||||
|
||||
- name: Cache cargo registry
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry
|
||||
~/.cargo/git
|
||||
key: ${{ runner.os }}-${{ matrix.target }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Cache target directory
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: target
|
||||
key: ${{ runner.os }}-${{ matrix.target }}-target-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Build
|
||||
run: cargo build --release --target ${{ matrix.target }}
|
||||
|
||||
- name: Prepare binary
|
||||
shell: bash
|
||||
run: |
|
||||
cd target/${{ matrix.target }}/release
|
||||
|
||||
# Use appropriate strip command for cross-compilation
|
||||
if [[ "${{ matrix.target }}" == "aarch64-unknown-linux-gnu" ]]; then
|
||||
aarch64-linux-gnu-strip ${{ matrix.artifact_name }} || echo "Strip failed, continuing..."
|
||||
elif [[ "${{ matrix.os }}" == "windows-latest" ]]; then
|
||||
strip ${{ matrix.artifact_name }} || echo "Strip failed, continuing..."
|
||||
else
|
||||
strip ${{ matrix.artifact_name }} || echo "Strip failed, continuing..."
|
||||
fi
|
||||
|
||||
# Create archive
|
||||
if [[ "${{ matrix.target }}" == *"windows"* ]]; then
|
||||
7z a ../../../${{ matrix.asset_name }}.zip ${{ matrix.artifact_name }}
|
||||
else
|
||||
tar czvf ../../../${{ matrix.asset_name }}.tar.gz ${{ matrix.artifact_name }}
|
||||
fi
|
||||
|
||||
- name: Upload binary
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.asset_name }}
|
||||
path: ${{ matrix.asset_name }}.tar.gz
|
||||
|
||||
release:
|
||||
name: Create Release
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Generate release notes
|
||||
run: |
|
||||
echo "## What's Changed" > release_notes.md
|
||||
echo "" >> release_notes.md
|
||||
echo "### Features" >> release_notes.md
|
||||
echo "- AI-powered static blog generator" >> release_notes.md
|
||||
echo "- AtProto OAuth integration" >> release_notes.md
|
||||
echo "- Automatic translation support" >> release_notes.md
|
||||
echo "- AI comment system" >> release_notes.md
|
||||
echo "" >> release_notes.md
|
||||
echo "### Platforms" >> release_notes.md
|
||||
echo "- Linux (x86_64, aarch64)" >> release_notes.md
|
||||
echo "- macOS (Intel, Apple Silicon)" >> release_notes.md
|
||||
echo "" >> release_notes.md
|
||||
echo "### Installation" >> release_notes.md
|
||||
echo "\`\`\`bash" >> release_notes.md
|
||||
echo "# Linux/macOS" >> release_notes.md
|
||||
echo "tar -xzf ailog-linux-x86_64.tar.gz" >> release_notes.md
|
||||
echo "chmod +x ailog" >> release_notes.md
|
||||
echo "sudo mv ailog /usr/local/bin/" >> release_notes.md
|
||||
echo "" >> release_notes.md
|
||||
echo "\`\`\`" >> release_notes.md
|
||||
|
||||
- name: Get tag name
|
||||
id: tag_name
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||
echo "tag=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ steps.tag_name.outputs.tag }}
|
||||
name: ailog ${{ steps.tag_name.outputs.tag }}
|
||||
body_path: release_notes.md
|
||||
draft: false
|
||||
prerelease: ${{ contains(steps.tag_name.outputs.tag, 'alpha') || contains(steps.tag_name.outputs.tag, 'beta') || contains(steps.tag_name.outputs.tag, 'rc') }}
|
||||
files: artifacts/*/ailog-*.tar.gz
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
10
.gitignore
vendored
10
.gitignore
vendored
@@ -5,16 +5,8 @@
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
cloudflare-config.yml
|
||||
my-blog/public/
|
||||
dist
|
||||
node_modules
|
||||
package-lock.json
|
||||
my-blog/static/assets/comment-atproto-*
|
||||
bin/ailog
|
||||
docs
|
||||
my-blog/static/index.html
|
||||
my-blog/templates/oauth-assets.html
|
||||
cloudflared-config.yml
|
||||
.config
|
||||
oauth-server-example
|
||||
atproto
|
||||
|
45
Cargo.toml
45
Cargo.toml
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ailog"
|
||||
version = "0.2.1"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
authors = ["syui"]
|
||||
description = "A static blog generator with AI features"
|
||||
@@ -10,16 +10,12 @@ license = "MIT"
|
||||
name = "ailog"
|
||||
path = "src/main.rs"
|
||||
|
||||
[lib]
|
||||
name = "ailog"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
pulldown-cmark = "0.11"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
tokio = { version = "1.40", features = ["rt-multi-thread", "macros", "fs", "net", "io-util", "sync", "time", "process", "signal"] }
|
||||
tokio = { version = "1.40", features = ["full"] }
|
||||
anyhow = "1.0"
|
||||
toml = "0.8"
|
||||
chrono = "0.4"
|
||||
@@ -30,7 +26,7 @@ fs_extra = "1.3"
|
||||
colored = "2.1"
|
||||
serde_yaml = "0.9"
|
||||
syntect = "5.2"
|
||||
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
rand = "0.8"
|
||||
sha2 = "0.10"
|
||||
base64 = "0.22"
|
||||
@@ -47,39 +43,12 @@ cookie = "0.18"
|
||||
syn = { version = "2.0", features = ["full", "parsing", "visit"] }
|
||||
quote = "1.0"
|
||||
ignore = "0.4"
|
||||
git2 = { version = "0.18", features = ["vendored-openssl", "vendored-libgit2", "ssh"], default-features = false }
|
||||
git2 = "0.18"
|
||||
regex = "1.0"
|
||||
# ATProto and stream monitoring dependencies
|
||||
tokio-tungstenite = { version = "0.21", features = ["rustls-tls-webpki-roots", "connect"], default-features = false }
|
||||
tokio-tungstenite = { version = "0.21", features = ["native-tls"] }
|
||||
futures-util = "0.3"
|
||||
tungstenite = { version = "0.21", features = ["rustls-tls-webpki-roots"], default-features = false }
|
||||
rpassword = "7.3"
|
||||
tungstenite = { version = "0.21", features = ["native-tls"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.14"
|
||||
|
||||
[profile.dev]
|
||||
# Speed up development builds
|
||||
opt-level = 0
|
||||
debug = true
|
||||
debug-assertions = true
|
||||
overflow-checks = true
|
||||
lto = false
|
||||
panic = 'unwind'
|
||||
incremental = true
|
||||
codegen-units = 256
|
||||
|
||||
[profile.release]
|
||||
# Optimize release builds for speed and size
|
||||
opt-level = 3
|
||||
debug = false
|
||||
debug-assertions = false
|
||||
overflow-checks = false
|
||||
lto = true
|
||||
panic = 'abort'
|
||||
incremental = false
|
||||
codegen-units = 1
|
||||
|
||||
[profile.dev.package."*"]
|
||||
# Optimize dependencies in dev builds
|
||||
opt-level = 3
|
||||
tempfile = "3.14"
|
32
Dockerfile
Normal file
32
Dockerfile
Normal file
@@ -0,0 +1,32 @@
|
||||
# Multi-stage build for ailog
|
||||
FROM rust:1.75 as builder
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY src ./src
|
||||
|
||||
RUN cargo build --release
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the binary
|
||||
COPY --from=builder /usr/src/app/target/release/ailog /usr/local/bin/ailog
|
||||
|
||||
# Copy blog content
|
||||
COPY my-blog ./blog
|
||||
|
||||
# Build static site
|
||||
RUN ailog build blog
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8080
|
||||
|
||||
# Run server
|
||||
CMD ["ailog", "serve", "blog"]
|
Binary file not shown.
222
claude.md
222
claude.md
@@ -1,227 +1,5 @@
|
||||
# エコシステム統合設計書
|
||||
|
||||
## 注意事項
|
||||
|
||||
`console.log`は絶対に書かないようにしてください。
|
||||
|
||||
ハードコードしないようにしてください。必ず、`./my-blog/config.toml`や`./oauth/.env.production`を使用するように。または`~/.config/syui/ai/log/config.json`を使用するように。
|
||||
|
||||
重複する名前のenvを作らないようにしてください。新しい環境変数を作る際は必ず検討してください。
|
||||
|
||||
```sh
|
||||
# ダメな例
|
||||
VITE_OAUTH_COLLECTION_USER=ai.syui.log.user
|
||||
VITE_OAUTH_COLLECTION_CHAT=ai.syui.log.chat
|
||||
```
|
||||
|
||||
## oauth appの設計
|
||||
|
||||
> ./oauth/.env.production
|
||||
|
||||
```sh
|
||||
VITE_ATPROTO_PDS=syu.is
|
||||
VITE_ADMIN_HANDLE=ai.syui.ai
|
||||
VITE_AI_HANDLE=ai.syui.ai
|
||||
VITE_OAUTH_COLLECTION=ai.syui.log
|
||||
```
|
||||
|
||||
これらは非常にシンプルな流れになっており、すべての項目は、共通します。短縮できる場合があります。handleは変わる可能性があるので、できる限りdidを使いましょう。
|
||||
|
||||
1. handleからpds, didを取得できる ... com.atproto.repo.describeRepo
|
||||
2. pdsが分かれば、pdsApi, bskyApi, plcApiを割り当てられる
|
||||
3. bskyApiが分かれば、getProfileでavatar-uriを取得できる ... app.bsky.actor.getProfile
|
||||
4. pdsAPiからアカウントにあるcollectionのrecordの情報を取得できる ... com.atproto.repo.listRecords
|
||||
|
||||
### コメントを表示する
|
||||
|
||||
1. VITE_ADMIN_HANDLEから管理者のhandleを取得する。
|
||||
2. VITE_ATPROTO_PDSから管理者のアカウントのpdsを取得する。
|
||||
3. pdsからpdsApi, bskApi, plcApiを割り当てる。
|
||||
|
||||
```rust
|
||||
match pds {
|
||||
"bsky.social" | "bsky.app" => NetworkConfig {
|
||||
pds_api: format!("https://{}", pds),
|
||||
plc_api: "https://plc.directory".to_string(),
|
||||
bsky_api: "https://public.api.bsky.app".to_string(),
|
||||
web_url: "https://bsky.app".to_string(),
|
||||
},
|
||||
"syu.is" => NetworkConfig {
|
||||
pds_api: "https://syu.is".to_string(),
|
||||
plc_api: "https://plc.syu.is".to_string(),
|
||||
bsky_api: "https://bsky.syu.is".to_string(),
|
||||
web_url: "https://web.syu.is".to_string(),
|
||||
},
|
||||
_ => {
|
||||
// Default to Bluesky network for unknown PDS
|
||||
NetworkConfig {
|
||||
pds_api: format!("https://{}", pds),
|
||||
plc_api: "https://plc.directory".to_string(),
|
||||
bsky_api: "https://public.api.bsky.app".to_string(),
|
||||
web_url: "https://bsky.app".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. 管理者アカウントであるVITE_ADMIN_HANDLEとVITE_ATPROTO_PDSから`ai.syui.log.user`というuserlistを取得する。
|
||||
|
||||
```sh
|
||||
curl -sL "https://${VITE_ATPROTO_PDS}/xrpc/com.atproto.repo.listRecords?repo=${VITE_ADMIN_HANDLE}&collection=ai.syui.log.user"
|
||||
---
|
||||
syui.ai
|
||||
```
|
||||
|
||||
5. ユーザーがわかったら、そのユーザーのpdsを判定する。
|
||||
|
||||
```sh
|
||||
curl -sL "https://bsky.social/xrpc/com.atproto.repo.describeRepo?repo=syui.ai" |jq -r ".didDoc.service.[].serviceEndpoint"
|
||||
---
|
||||
https://shiitake.us-east.host.bsky.network
|
||||
|
||||
curl -sL "https://bsky.social/xrpc/com.atproto.repo.describeRepo?repo=syui.ai" |jq -r ".did"
|
||||
---
|
||||
did:plc:uqzpqmrjnptsxezjx4xuh2mn
|
||||
```
|
||||
|
||||
6. pdsからpdsApi, bskApi, plcApiを割り当てる。
|
||||
|
||||
```rust
|
||||
match pds {
|
||||
"bsky.social" | "bsky.app" => NetworkConfig {
|
||||
pds_api: format!("https://{}", pds),
|
||||
plc_api: "https://plc.directory".to_string(),
|
||||
bsky_api: "https://public.api.bsky.app".to_string(),
|
||||
web_url: "https://bsky.app".to_string(),
|
||||
},
|
||||
"syu.is" => NetworkConfig {
|
||||
pds_api: "https://syu.is".to_string(),
|
||||
plc_api: "https://plc.syu.is".to_string(),
|
||||
bsky_api: "https://bsky.syu.is".to_string(),
|
||||
web_url: "https://web.syu.is".to_string(),
|
||||
},
|
||||
_ => {
|
||||
// Default to Bluesky network for unknown PDS
|
||||
NetworkConfig {
|
||||
pds_api: format!("https://{}", pds),
|
||||
plc_api: "https://plc.directory".to_string(),
|
||||
bsky_api: "https://public.api.bsky.app".to_string(),
|
||||
web_url: "https://bsky.app".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
7. ユーザーの情報を取得、表示する
|
||||
|
||||
```sh
|
||||
bsky_api=https://public.api.bsky.app
|
||||
user_did=did:plc:uqzpqmrjnptsxezjx4xuh2mn
|
||||
curl -sL "$bsky_api/xrpc/app.bsky.actor.getProfile?actor=$user_did"|jq -r .avatar
|
||||
---
|
||||
https://cdn.bsky.app/img/avatar/plain/did:plc:uqzpqmrjnptsxezjx4xuh2mn/bafkreid6kcc5pnn4b3ar7mj6vi3eiawhxgkcrw3edgbqeacyrlnlcoetea@jpeg
|
||||
```
|
||||
|
||||
### AIの情報を表示する
|
||||
|
||||
AIが持つ`ai.syui.log.chat.lang`, `ai.syui.log.chat.comment`を表示します。
|
||||
|
||||
なお、これは通常、`VITE_ADMIN_HANDLE`にputRecordされます。そこから情報を読み込みます。`VITE_AI_HANDLE`はそのrecordの`author`のところに入ります。
|
||||
|
||||
```json
|
||||
"author": {
|
||||
"did": "did:plc:4hqjfn7m6n5hno3doamuhgef",
|
||||
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:4hqjfn7m6n5hno3doamuhgef/bafkreiaxkv624mffw3cfyi67ufxtwuwsy2mjw2ygezsvtd44ycbgkfdo2a@jpeg",
|
||||
"handle": "yui.syui.ai",
|
||||
"displayName": "ai"
|
||||
}
|
||||
```
|
||||
|
||||
1. VITE_ADMIN_HANDLEから管理者のhandleを取得する。
|
||||
2. VITE_ATPROTO_PDSから管理者のアカウントのpdsを取得する。
|
||||
3. pdsからpdsApi, bskApi, plcApiを割り当てる。
|
||||
|
||||
```rust
|
||||
match pds {
|
||||
"bsky.social" | "bsky.app" => NetworkConfig {
|
||||
pds_api: format!("https://{}", pds),
|
||||
plc_api: "https://plc.directory".to_string(),
|
||||
bsky_api: "https://public.api.bsky.app".to_string(),
|
||||
web_url: "https://bsky.app".to_string(),
|
||||
},
|
||||
"syu.is" => NetworkConfig {
|
||||
pds_api: "https://syu.is".to_string(),
|
||||
plc_api: "https://plc.syu.is".to_string(),
|
||||
bsky_api: "https://bsky.syu.is".to_string(),
|
||||
web_url: "https://web.syu.is".to_string(),
|
||||
},
|
||||
_ => {
|
||||
// Default to Bluesky network for unknown PDS
|
||||
NetworkConfig {
|
||||
pds_api: format!("https://{}", pds),
|
||||
plc_api: "https://plc.directory".to_string(),
|
||||
bsky_api: "https://public.api.bsky.app".to_string(),
|
||||
web_url: "https://bsky.app".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. 管理者アカウントであるVITE_ADMIN_HANDLEとVITE_ATPROTO_PDSから`ai.syui.log.chat.lang`, `ai.syui.log.chat.comment`を取得する。
|
||||
|
||||
```sh
|
||||
curl -sL "https://${VITE_ATPROTO_PDS}/xrpc/com.atproto.repo.listRecords?repo=${VITE_ADMIN_HANDLE}&collection=ai.syui.log.chat.comment"
|
||||
```
|
||||
|
||||
5. AIのprofileを取得する。
|
||||
|
||||
```sh
|
||||
curl -sL "https://${VITE_ATPROTO_PDS}/xrpc/com.atproto.repo.describeRepo?repo=$VITE_AI_HANDLE" |jq -r ".didDoc.service.[].serviceEndpoint"
|
||||
---
|
||||
https://syu.is
|
||||
|
||||
curl -sL "https://${VITE_ATPROTO_PDS}/xrpc/com.atproto.repo.describeRepo?repo=$VITE_AI_HANDLE" |jq -r ".did"
|
||||
did:plc:6qyecktefllvenje24fcxnie
|
||||
```
|
||||
|
||||
6. pdsからpdsApi, bskApi, plcApiを割り当てる。
|
||||
|
||||
```rust
|
||||
match pds {
|
||||
"bsky.social" | "bsky.app" => NetworkConfig {
|
||||
pds_api: format!("https://{}", pds),
|
||||
plc_api: "https://plc.directory".to_string(),
|
||||
bsky_api: "https://public.api.bsky.app".to_string(),
|
||||
web_url: "https://bsky.app".to_string(),
|
||||
},
|
||||
"syu.is" => NetworkConfig {
|
||||
pds_api: "https://syu.is".to_string(),
|
||||
plc_api: "https://plc.syu.is".to_string(),
|
||||
bsky_api: "https://bsky.syu.is".to_string(),
|
||||
web_url: "https://web.syu.is".to_string(),
|
||||
},
|
||||
_ => {
|
||||
// Default to Bluesky network for unknown PDS
|
||||
NetworkConfig {
|
||||
pds_api: format!("https://{}", pds),
|
||||
plc_api: "https://plc.directory".to_string(),
|
||||
bsky_api: "https://public.api.bsky.app".to_string(),
|
||||
web_url: "https://bsky.app".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
7. AIの情報を取得、表示する
|
||||
|
||||
```sh
|
||||
bsky_api=https://bsky.syu.is
|
||||
user_did=did:plc:6qyecktefllvenje24fcxnie
|
||||
curl -sL "$bsky_api/xrpc/app.bsky.actor.getProfile?actor=$user_did"|jq -r .avatar
|
||||
---
|
||||
https://bsky.syu.is/img/avatar/plain/did:plc:6qyecktefllvenje24fcxnie/bafkreiet4pwlnshk7igra5flf2fuxpg2bhvf2apts4rqwcr56hzhgycii4@jpeg
|
||||
```
|
||||
|
||||
## 中核思想
|
||||
- **存在子理論**: この世界で最も小さいもの(存在子/ai)の探求
|
||||
- **唯一性原則**: 現実の個人の唯一性をすべてのシステムで担保
|
||||
|
18
cloudflared-config.yml
Normal file
18
cloudflared-config.yml
Normal file
@@ -0,0 +1,18 @@
|
||||
tunnel: ec5a422d-7678-4e73-bf38-6105ffd4766a
|
||||
credentials-file: /Users/syui/.cloudflared/ec5a422d-7678-4e73-bf38-6105ffd4766a.json
|
||||
|
||||
ingress:
|
||||
- hostname: log.syui.ai
|
||||
service: http://localhost:4173
|
||||
originRequest:
|
||||
noHappyEyeballs: true
|
||||
|
||||
- hostname: ollama.syui.ai
|
||||
service: http://localhost:11434
|
||||
originRequest:
|
||||
noHappyEyeballs: true
|
||||
httpHostHeader: "localhost:11434"
|
||||
# Cloudflare Accessを無効化する場合は以下をコメントアウト
|
||||
# accessPolicy: bypass
|
||||
|
||||
- service: http_status:404
|
@@ -7,7 +7,6 @@ author = "syui"
|
||||
|
||||
[build]
|
||||
highlight_code = true
|
||||
highlight_theme = "Monokai"
|
||||
minify = false
|
||||
|
||||
[ai]
|
||||
@@ -16,18 +15,16 @@ 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"
|
||||
system_prompt = "あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"
|
||||
handle = "ai.syui.ai"
|
||||
#num_predict = 200
|
||||
model = "gemma3:2b"
|
||||
host = "https://ollama.syui.ai"
|
||||
system_prompt = "you are a helpful ai assistant trained on this blog's content. you can answer questions about the articles, provide insights, and help users understand the topics discussed."
|
||||
ai_did = "did:plc:4hqjfn7m6n5hno3doamuhgef"
|
||||
|
||||
[oauth]
|
||||
json = "client-metadata.json"
|
||||
redirect = "oauth/callback"
|
||||
admin = "ai.syui.ai"
|
||||
collection = "ai.syui.log"
|
||||
pds = "syu.is"
|
||||
handle_list = ["syui.syui.ai", "ai.syui.ai", "ai.ai"]
|
||||
admin = "did:plc:uqzpqmrjnptsxezjx4xuh2mn"
|
||||
collection_comment = "ai.syui.log"
|
||||
collection_user = "ai.syui.log.user"
|
||||
collection_chat = "ai.syui.log.chat"
|
||||
bsky_api = "https://public.api.bsky.app"
|
||||
|
@@ -1,14 +1,18 @@
|
||||
---
|
||||
title: "静的サイトジェネレータを作った"
|
||||
slug: "ailog"
|
||||
slug: "ailog-system-introduction"
|
||||
date: "2025-06-12"
|
||||
tags: ["blog", "rust", "mcp", "atp"]
|
||||
language: ["ja", "en"]
|
||||
---
|
||||
|
||||
rustで静的サイトジェネレータを作りました。[ailog](https://git.syui.ai/ai/log)といいます。`hugo`からの移行になります。
|
||||
rustで静的サイトジェネレータを作ることにしました。[ailog](https://git.syui.ai/ai/log)といいます。`hugo`からの移行になります。
|
||||
|
||||
`ailog`は、最初にatproto-comment-system(oauth)とask-AIという機能をつけました。
|
||||
ブログを書く環境もこれから変わってくると思っていて、例えば、`docs`, `readme`, `blog`などはAIが生成、または支援することになるだろうと予測しています。langの自動生成もAIが担当することになるでしょう。
|
||||
|
||||
これは、音声に限らず、プログラミング言語から、osなど、様々なtranslateがAIの自動生成になるかもしれません。
|
||||
|
||||
`ailog`は、最初にatproto-comment-system(oauth)とask-AIというAI機能をつけました。
|
||||
|
||||
## quick start
|
||||
|
||||
@@ -17,7 +21,7 @@ $ git clone https://git.syui.ai/ai/log
|
||||
$ cd log
|
||||
$ cargo build
|
||||
$ ./target/debug/ailog init my-blog
|
||||
$ ./target/debug/ailog serve my-blog
|
||||
$ ./target/debug/ailog server my-blog
|
||||
```
|
||||
|
||||
## install
|
||||
@@ -30,7 +34,7 @@ $ export RUSTUP_HOME="$HOME/.rustup"
|
||||
$ export PATH="$HOME/.cargo/bin:$PATH"
|
||||
---
|
||||
$ which ailog
|
||||
$ ailog -h
|
||||
$ ailog
|
||||
```
|
||||
|
||||
## build deploy
|
||||
@@ -57,28 +61,24 @@ $ npm run build
|
||||
$ npm run preview
|
||||
```
|
||||
|
||||
```sh:ouath/.env.production
|
||||
```sh
|
||||
# Production environment variables
|
||||
VITE_APP_HOST=https://syui.ai
|
||||
VITE_OAUTH_CLIENT_ID=https://syui.ai/client-metadata.json
|
||||
VITE_OAUTH_REDIRECT_URI=https://syui.ai/oauth/callback
|
||||
VITE_APP_HOST=https://example.com
|
||||
VITE_OAUTH_CLIENT_ID=https://example.com/client-metadata.json
|
||||
VITE_OAUTH_REDIRECT_URI=https://example.com/oauth/callback
|
||||
VITE_ADMIN_DID=did:plc:uqzpqmrjnptsxezjx4xuh2mn
|
||||
|
||||
# Base collection (all others are derived via getCollectionNames)
|
||||
VITE_OAUTH_COLLECTION=ai.syui.log
|
||||
# Collection names for OAuth app
|
||||
VITE_COLLECTION_COMMENT=ai.syui.log
|
||||
VITE_COLLECTION_USER=ai.syui.log.user
|
||||
VITE_COLLECTION_CHAT=ai.syui.log.chat
|
||||
|
||||
# AI Configuration
|
||||
VITE_AI_ENABLED=true
|
||||
VITE_AI_ASK_AI=true
|
||||
VITE_AI_PROVIDER=ollama
|
||||
VITE_AI_MODEL=gemma3:4b
|
||||
VITE_AI_HOST=https://ollama.syui.ai
|
||||
VITE_AI_SYSTEM_PROMPT="ai"
|
||||
VITE_AI_DID=did:plc:4hqjfn7m6n5hno3doamuhgef
|
||||
# Collection names for ailog (backward compatibility)
|
||||
AILOG_COLLECTION_COMMENT=ai.syui.log
|
||||
AILOG_COLLECTION_USER=ai.syui.log.user
|
||||
|
||||
# API Configuration
|
||||
VITE_BSKY_PUBLIC_API=https://public.api.bsky.app
|
||||
VITE_ATPROTO_API=https://bsky.social
|
||||
```
|
||||
|
||||
これは`ailog oauth build my-blog`で`./my-blog/config.toml`から`./oauth/.env.production`が生成されます。
|
||||
@@ -87,7 +87,7 @@ VITE_ATPROTO_API=https://bsky.social
|
||||
$ ailog oauth build my-blog
|
||||
```
|
||||
|
||||
### use
|
||||
### 解説
|
||||
|
||||
簡単に説明すると、`./oauth`で生成するのが`atproto-comment-system`です。
|
||||
|
||||
@@ -119,8 +119,15 @@ $ cloudflared tunnel --config cloudflared-config.yml run
|
||||
$ cloudflared tunnel route dns ${uuid} example.com
|
||||
```
|
||||
|
||||
以下の2つのcollection recordを生成します。ユーザーには`ai.syui.log`が生成され、ここにコメントが記録されます。それを取得して表示しています。`ai.syui.log.user`は管理者である`VITE_ADMIN_DID`用です。
|
||||
|
||||
```sh
|
||||
$ ailog auth init
|
||||
VITE_COLLECTION_COMMENT=ai.syui.log
|
||||
VITE_COLLECTION_USER=ai.syui.log.user
|
||||
```
|
||||
|
||||
```sh
|
||||
$ ailog auth login
|
||||
$ ailog stream server
|
||||
```
|
||||
|
||||
@@ -132,26 +139,5 @@ $ ailog stream server
|
||||
|
||||
`ask-AI`の仕組みは割愛します。後に変更される可能性が高いと思います。
|
||||
|
||||
`llm`, `mcp`, `atproto`などの組み合わせです。
|
||||
|
||||
現在、`/index.json`を監視して、更新があれば、翻訳などを行い自動ポストする機能があります。
|
||||
|
||||
## code syntax
|
||||
|
||||
```zsh:/path/to/test.zsh
|
||||
# comment
|
||||
d=${0:a:h}
|
||||
```
|
||||
|
||||
```rust:/path/to/test.rs
|
||||
// This is a comment
|
||||
fn main() {
|
||||
println!("Hello, world!");
|
||||
}
|
||||
```
|
||||
|
||||
```js:/path/to/test.js
|
||||
// This is a comment
|
||||
console.log("Hello, world!");
|
||||
```
|
||||
local llm, mcp, atprotoと組み合わせです。
|
||||
|
||||
|
@@ -1,66 +0,0 @@
|
||||
---
|
||||
title: "ブログを移行した"
|
||||
slug: "blog"
|
||||
date: 2025-06-14
|
||||
tags: ["blog", "cloudflare", "github"]
|
||||
draft: false
|
||||
---
|
||||
|
||||
ブログを移行しました。過去のブログは[syui.github.io](https://syui.github.io)にありあます。
|
||||
|
||||
1. `gh-pages`から`cf-pages`への移行になります。
|
||||
2. 自作の`ailog`でbuildしています。
|
||||
3. 特徴としては、`atproto`, `AI`との連携です。
|
||||
|
||||
```yml:.github/workflows/cloudflare-pages.yml
|
||||
name: Deploy to Cloudflare Pages
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
deployments: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Rust
|
||||
uses: actions-rs/toolchain@v1
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
- name: Build ailog
|
||||
run: cargo build --release
|
||||
|
||||
- name: Build site with ailog
|
||||
run: |
|
||||
cd my-blog
|
||||
../target/release/ailog build
|
||||
|
||||
- name: List public directory
|
||||
run: |
|
||||
ls -la my-blog/public/
|
||||
|
||||
- name: Deploy to Cloudflare Pages
|
||||
uses: cloudflare/pages-action@v1
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
projectName: ${{ secrets.CLOUDFLARE_PROJECT_NAME }}
|
||||
directory: my-blog/public
|
||||
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
wranglerVersion: '3'
|
||||
```
|
||||
|
||||
## url
|
||||
|
||||
- [https://syui.pages.dev](https://syui.pages.dev)
|
||||
- [https://syui.github.io](https://syui.github.io)
|
@@ -1,7 +0,0 @@
|
||||
{{ $dateFormat := default "Mon Jan 2, 2006" (index .Site.Params "date_format") }}
|
||||
{{ $utcFormat := "2006-01-02T15:04:05Z07:00" }}
|
||||
{{- $.Scratch.Add "index" slice -}}
|
||||
{{- range .Site.RegularPages -}}
|
||||
{{- $.Scratch.Add "index" (dict "title" .Title "tags" .Params.tags "description" .Description "categories" .Params.categories "contents" .Plain "href" .Permalink "utc_time" (.Date.Format $utcFormat) "formated_time" (.Date.Format $dateFormat)) -}}
|
||||
{{- end -}}
|
||||
{{- $.Scratch.Get "index" | jsonify -}}
|
@@ -1,20 +0,0 @@
|
||||
# Production environment variables
|
||||
VITE_APP_HOST=https://syui.ai
|
||||
VITE_OAUTH_CLIENT_ID=https://syui.ai/client-metadata.json
|
||||
VITE_OAUTH_REDIRECT_URI=https://syui.ai/oauth/callback
|
||||
|
||||
# Handle-based Configuration (DIDs resolved at runtime)
|
||||
VITE_ATPROTO_PDS=syu.is
|
||||
VITE_ADMIN_HANDLE=ai.syui.ai
|
||||
VITE_AI_HANDLE=ai.syui.ai
|
||||
VITE_OAUTH_COLLECTION=ai.syui.log
|
||||
VITE_ATPROTO_WEB_URL=https://bsky.app
|
||||
VITE_ATPROTO_HANDLE_LIST=["syui.syui.ai", "ai.syui.ai", "ai.ai"]
|
||||
|
||||
# AI Configuration
|
||||
VITE_AI_ENABLED=true
|
||||
VITE_AI_ASK_AI=true
|
||||
VITE_AI_PROVIDER=ollama
|
||||
VITE_AI_MODEL=gemma3:4b
|
||||
VITE_AI_HOST=http://localhost:11434
|
||||
VITE_AI_SYSTEM_PROMPT="あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"
|
@@ -16,32 +16,11 @@
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
/css/*
|
||||
Content-Type: text/css
|
||||
Cache-Control: no-cache
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
/*.js
|
||||
Content-Type: application/javascript
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
/assets/*.js
|
||||
Content-Type: application/javascript
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
# Ensure ES6 modules are served correctly
|
||||
/assets/comment-atproto-*.js
|
||||
Content-Type: text/javascript; charset=utf-8
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
# All JS assets
|
||||
/assets/*-*.js
|
||||
Content-Type: text/javascript; charset=utf-8
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
|
||||
# CSS assets
|
||||
/assets/*.css
|
||||
Content-Type: text/css
|
||||
Cache-Control: public, max-age=60
|
||||
|
||||
/posts/*
|
||||
Cache-Control: public, max-age=3600
|
||||
|
||||
|
@@ -1,3 +1,9 @@
|
||||
# AI機能をai.gpt MCP serverにリダイレクト
|
||||
/api/ask https://ai-gpt-mcp.syui.ai/ask 200
|
||||
|
||||
# Ollama API proxy (Cloudflare Workers)
|
||||
/api/ollama-proxy https://ollama-proxy.YOUR-SUBDOMAIN.workers.dev/:splat 200
|
||||
|
||||
# OAuth routes
|
||||
/oauth/* /oauth/index.html 200
|
||||
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 23 KiB |
1
my-blog/static/assets/comment-atproto-B330B6QX.css
Normal file
1
my-blog/static/assets/comment-atproto-B330B6QX.css
Normal file
File diff suppressed because one or more lines are too long
122
my-blog/static/assets/comment-atproto-MOwlkOMr.js
Normal file
122
my-blog/static/assets/comment-atproto-MOwlkOMr.js
Normal file
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"client_id": "https://syui.ai/client-metadata.json",
|
||||
"client_name": "ai.log",
|
||||
"client_name": "ai.card",
|
||||
"client_uri": "https://syui.ai",
|
||||
"logo_uri": "https://syui.ai/favicon.ico",
|
||||
"tos_uri": "https://syui.ai/terms",
|
||||
@@ -21,4 +21,4 @@
|
||||
"subject_type": "public",
|
||||
"application_type": "web",
|
||||
"dpop_bound_access_tokens": true
|
||||
}
|
||||
}
|
File diff suppressed because it is too large
Load Diff
@@ -1,342 +0,0 @@
|
||||
/* SVG Animation Package - Dependency-free standalone package
|
||||
* Based on svg-animation-particle-circle.css
|
||||
* Theme color integration with CSS variables
|
||||
*/
|
||||
|
||||
/* Theme-based color variables for particles */
|
||||
:root {
|
||||
--particle-color-1: #f40; /* theme-color base */
|
||||
--particle-color-2: #f50; /* theme-color +0.1 brightness */
|
||||
--particle-color-3: #f60; /* theme-color +0.2 brightness */
|
||||
--particle-color-4: #f70; /* theme-color +0.3 brightness */
|
||||
--particle-color-5: #f80; /* theme-color +0.4 brightness */
|
||||
--explosion-color: #f30; /* theme-color -0.1 brightness */
|
||||
--syui-color: #f40; /* main theme color */
|
||||
}
|
||||
|
||||
/* Core SVG button setup */
|
||||
.likeButton {
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Remove debug animation and restore hover functionality */
|
||||
|
||||
.likeButton .border {
|
||||
fill: white;
|
||||
}
|
||||
|
||||
/* Explosion circle - initially hidden */
|
||||
.likeButton .explosion {
|
||||
transform-origin: center center;
|
||||
transform: scale(1);
|
||||
stroke: var(--explosion-color);
|
||||
fill: none;
|
||||
opacity: 0;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
/* Particle layer - initially hidden */
|
||||
.likeButton .particleLayer {
|
||||
opacity: 0;
|
||||
transform: scale(0); /* Ensure particles start hidden */
|
||||
}
|
||||
|
||||
.likeButton .particleLayer circle {
|
||||
opacity: 0;
|
||||
transform-origin: center center; /* Fixed from 250px 250px */
|
||||
transform: scale(0);
|
||||
}
|
||||
|
||||
/* Syui logo - main animation target */
|
||||
.likeButton .syui {
|
||||
fill: var(--syui-color);
|
||||
transform: scale(1);
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
/* Hover trigger - replaces jQuery */
|
||||
.likeButton:hover .explosion {
|
||||
animation: explosionAnime 800ms forwards;
|
||||
}
|
||||
|
||||
.likeButton:hover .particleLayer {
|
||||
animation: particleLayerAnime 800ms forwards;
|
||||
}
|
||||
|
||||
.likeButton:hover .syui,
|
||||
.likeButton:hover path.syui {
|
||||
animation: syuiDeluxeAnime 400ms forwards;
|
||||
}
|
||||
|
||||
/* Individual particle animations */
|
||||
.likeButton:hover .particleLayer circle:nth-child(1) {
|
||||
animation: particleAnimate1 800ms;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.likeButton:hover .particleLayer circle:nth-child(2) {
|
||||
animation: particleAnimate2 800ms;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.likeButton:hover .particleLayer circle:nth-child(3) {
|
||||
animation: particleAnimate3 800ms;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.likeButton:hover .particleLayer circle:nth-child(4) {
|
||||
animation: particleAnimate4 800ms;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.likeButton:hover .particleLayer circle:nth-child(5) {
|
||||
animation: particleAnimate5 800ms;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.likeButton:hover .particleLayer circle:nth-child(6) {
|
||||
animation: particleAnimate6 800ms;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.likeButton:hover .particleLayer circle:nth-child(7) {
|
||||
animation: particleAnimate7 800ms;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.likeButton:hover .particleLayer circle:nth-child(8) {
|
||||
animation: particleAnimate8 800ms;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.likeButton:hover .particleLayer circle:nth-child(9) {
|
||||
animation: particleAnimate9 800ms;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.likeButton:hover .particleLayer circle:nth-child(10) {
|
||||
animation: particleAnimate10 800ms;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.likeButton:hover .particleLayer circle:nth-child(11) {
|
||||
animation: particleAnimate11 800ms;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.likeButton:hover .particleLayer circle:nth-child(12) {
|
||||
animation: particleAnimate12 800ms;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.likeButton:hover .particleLayer circle:nth-child(13) {
|
||||
animation: particleAnimate13 800ms;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
.likeButton:hover .particleLayer circle:nth-child(14) {
|
||||
animation: particleAnimate14 800ms;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
/* Keyframe animations */
|
||||
@keyframes explosionAnime {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.01);
|
||||
}
|
||||
1% {
|
||||
opacity: 1;
|
||||
transform: scale(0.01);
|
||||
}
|
||||
5% {
|
||||
stroke-width: 200;
|
||||
}
|
||||
20% {
|
||||
stroke-width: 300;
|
||||
}
|
||||
50% {
|
||||
stroke: var(--particle-color-3);
|
||||
transform: scale(1.1);
|
||||
stroke-width: 1;
|
||||
}
|
||||
50.1% {
|
||||
stroke-width: 0;
|
||||
}
|
||||
100% {
|
||||
stroke: var(--particle-color-3);
|
||||
transform: scale(1.1);
|
||||
stroke-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes particleLayerAnime {
|
||||
0% {
|
||||
transform: translate(0, 0);
|
||||
opacity: 0;
|
||||
}
|
||||
30% {
|
||||
opacity: 0;
|
||||
}
|
||||
31% {
|
||||
opacity: 1;
|
||||
}
|
||||
60% {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
70% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(0, -20px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Syui Deluxe Animation - Based on 2019 blog post */
|
||||
@keyframes syuiDeluxeAnime {
|
||||
0% {
|
||||
fill: var(--syui-color);
|
||||
transform: scale(1) translate(0%, 0%);
|
||||
}
|
||||
40% {
|
||||
fill: color-mix(in srgb, var(--syui-color) 40%, transparent);
|
||||
transform: scale(1, 0.9) translate(-9%, 9%);
|
||||
}
|
||||
50% {
|
||||
fill: color-mix(in srgb, var(--syui-color) 70%, transparent);
|
||||
transform: scale(1, 0.9) translate(-7%, 7%);
|
||||
}
|
||||
60% {
|
||||
transform: scale(1) translate(-7%, 7%);
|
||||
}
|
||||
70% {
|
||||
transform: scale(1.04) translate(-5%, 5%);
|
||||
}
|
||||
80% {
|
||||
fill: color-mix(in srgb, var(--syui-color) 60%, transparent);
|
||||
transform: scale(1.04) translate(-5%, 5%);
|
||||
}
|
||||
90% {
|
||||
fill: var(--particle-color-5); /* 爆発の閃光 */
|
||||
transform: scale(1) translate(0%);
|
||||
}
|
||||
100% {
|
||||
fill: var(--syui-color);
|
||||
transform: scale(1, 1) translate(0%, 0%);
|
||||
}
|
||||
}
|
||||
|
||||
/* Individual particle animations */
|
||||
@keyframes particleAnimate1 {
|
||||
0% { transform: translate(0, 0); }
|
||||
30% { opacity: 1; transform: translate(0, 0); }
|
||||
80% { transform: translate(-16px, -59px); }
|
||||
90% { transform: translate(-16px, -59px); }
|
||||
100% { opacity: 1; transform: translate(-16px, -59px); }
|
||||
}
|
||||
|
||||
@keyframes particleAnimate2 {
|
||||
0% { transform: translate(0, 0); }
|
||||
30% { opacity: 1; transform: translate(0, 0); }
|
||||
80% { transform: translate(41px, 43px); }
|
||||
90% { transform: translate(41px, 43px); }
|
||||
100% { opacity: 1; transform: translate(41px, 43px); }
|
||||
}
|
||||
|
||||
@keyframes particleAnimate3 {
|
||||
0% { transform: translate(0, 0); }
|
||||
30% { opacity: 1; transform: translate(0, 0); }
|
||||
80% { transform: translate(50px, -48px); }
|
||||
90% { transform: translate(50px, -48px); }
|
||||
100% { opacity: 1; transform: translate(50px, -48px); }
|
||||
}
|
||||
|
||||
@keyframes particleAnimate4 {
|
||||
0% { transform: translate(0, 0); }
|
||||
30% { opacity: 1; transform: translate(0, 0); }
|
||||
80% { transform: translate(-39px, 36px); }
|
||||
90% { transform: translate(-39px, 36px); }
|
||||
100% { opacity: 1; transform: translate(-39px, 36px); }
|
||||
}
|
||||
|
||||
@keyframes particleAnimate5 {
|
||||
0% { transform: translate(0, 0); }
|
||||
30% { opacity: 1; transform: translate(0, 0); }
|
||||
80% { transform: translate(-39px, 32px); }
|
||||
90% { transform: translate(-39px, 32px); }
|
||||
100% { opacity: 1; transform: translate(-39px, 32px); }
|
||||
}
|
||||
|
||||
@keyframes particleAnimate6 {
|
||||
0% { transform: translate(0, 0); }
|
||||
30% { opacity: 1; transform: translate(0, 0); }
|
||||
80% { transform: translate(48px, 6px); }
|
||||
90% { transform: translate(48px, 6px); }
|
||||
100% { opacity: 1; transform: translate(48px, 6px); }
|
||||
}
|
||||
|
||||
@keyframes particleAnimate7 {
|
||||
0% { transform: translate(0, 0); }
|
||||
30% { opacity: 1; transform: translate(0, 0); }
|
||||
80% { transform: translate(-69px, -36px); }
|
||||
90% { transform: translate(-69px, -36px); }
|
||||
100% { opacity: 1; transform: translate(-69px, -36px); }
|
||||
}
|
||||
|
||||
@keyframes particleAnimate8 {
|
||||
0% { transform: translate(0, 0); }
|
||||
30% { opacity: 1; transform: translate(0, 0); }
|
||||
80% { transform: translate(-12px, -52px); }
|
||||
90% { transform: translate(-12px, -52px); }
|
||||
100% { opacity: 1; transform: translate(-12px, -52px); }
|
||||
}
|
||||
|
||||
@keyframes particleAnimate9 {
|
||||
0% { transform: translate(0, 0); }
|
||||
30% { opacity: 1; transform: translate(0, 0); }
|
||||
80% { transform: translate(-43px, -21px); }
|
||||
90% { transform: translate(-43px, -21px); }
|
||||
100% { opacity: 1; transform: translate(-43px, -21px); }
|
||||
}
|
||||
|
||||
@keyframes particleAnimate10 {
|
||||
0% { transform: translate(0, 0); }
|
||||
30% { opacity: 1; transform: translate(0, 0); }
|
||||
80% { transform: translate(-10px, 47px); }
|
||||
90% { transform: translate(-10px, 47px); }
|
||||
100% { opacity: 1; transform: translate(-10px, 47px); }
|
||||
}
|
||||
|
||||
@keyframes particleAnimate11 {
|
||||
0% { transform: translate(0, 0); }
|
||||
30% { opacity: 1; transform: translate(0, 0); }
|
||||
80% { transform: translate(66px, -9px); }
|
||||
90% { transform: translate(66px, -9px); }
|
||||
100% { opacity: 1; transform: translate(66px, -9px); }
|
||||
}
|
||||
|
||||
@keyframes particleAnimate12 {
|
||||
0% { transform: translate(0, 0); }
|
||||
30% { opacity: 1; transform: translate(0, 0); }
|
||||
80% { transform: translate(40px, -45px); }
|
||||
90% { transform: translate(40px, -45px); }
|
||||
100% { opacity: 1; transform: translate(40px, -45px); }
|
||||
}
|
||||
|
||||
@keyframes particleAnimate13 {
|
||||
0% { transform: translate(0, 0); }
|
||||
30% { opacity: 1; transform: translate(0, 0); }
|
||||
80% { transform: translate(29px, 24px); }
|
||||
90% { transform: translate(29px, 24px); }
|
||||
100% { opacity: 1; transform: translate(29px, 24px); }
|
||||
}
|
||||
|
||||
@keyframes particleAnimate14 {
|
||||
0% { transform: translate(0, 0); }
|
||||
30% { opacity: 1; transform: translate(0, 0); }
|
||||
80% { transform: translate(-10px, 50px); }
|
||||
90% { transform: translate(-10px, 50px); }
|
||||
100% { opacity: 1; transform: translate(-10px, 50px); }
|
||||
}
|
Binary file not shown.
Before Width: | Height: | Size: 84 KiB |
Binary file not shown.
Before Width: | Height: | Size: 23 KiB |
@@ -1,22 +0,0 @@
|
||||
<svg width="77pt" height="77pt" viewBox="0 0 512 512" class="likeButton" >
|
||||
<circle class="explosion" r="150" cx="250" cy="250"></circle>
|
||||
<g class="particleLayer">
|
||||
<circle fill="#ef454aba" cx="130" cy="126.5" r="12.5"/>
|
||||
<circle fill="#ef454acc" cx="411" cy="313.5" r="12.5"/>
|
||||
<circle fill="#ef454aba" cx="279" cy="86.5" r="12.5"/>
|
||||
<circle fill="#ef454aba" cx="155" cy="390.5" r="12.5"/>
|
||||
<circle fill="#ef454aba" cx="89" cy="292.5" r="10.5"/>
|
||||
<circle fill="#ef454aba" cx="414" cy="282.5" r="10.5"/>
|
||||
<circle fill="#ef454a91" cx="115" cy="149.5" r="10.5"/>
|
||||
<circle fill="#ef454aba" cx="250" cy="80.5" r="10.5"/>
|
||||
<circle fill="#ef454aba" cx="78" cy="261.5" r="10.5"/>
|
||||
<circle fill="#ef454a91" cx="182" cy="402.5" r="10.5"/>
|
||||
<circle fill="#ef454aba" cx="401.5" cy="166" r="13"/>
|
||||
<circle fill="#ef454aba" cx="379" cy="141.5" r="10.5"/>
|
||||
<circle fill="#ef454a91" cx="327" cy="397.5" r="10.5"/>
|
||||
<circle fill="#ef454aba" cx="296" cy="392.5" r="10.5"/>
|
||||
</g>
|
||||
<g transform="translate(0,512) scale(0.1,-0.1)" fill="#000000" class="icon_syui">
|
||||
<path class="syui" d="M3660 4460 c-11 -11 -33 -47 -48 -80 l-29 -60 -12 38 c-27 88 -58 92 -98 11 -35 -70 -73 -159 -73 -169 0 -6 -5 -10 -10 -10 -6 0 -15 -10 -21 -22 -33 -73 -52 -92 -47 -48 2 26 -1 35 -14 38 -16 3 -168 -121 -168 -138 0 -5 -13 -16 -28 -24 -24 -13 -35 -12 -87 0 -221 55 -231 56 -480 56 -219 1 -247 -1 -320 -22 -44 -12 -96 -26 -115 -30 -57 -13 -122 -39 -200 -82 -8 -4 -31 -14 -50 -23 -41 -17 -34 -13 -146 -90 -87 -59 -292 -252 -351 -330 -63 -83 -143 -209 -143 -225 0 -10 -7 -23 -15 -30 -8 -7 -15 -17 -15 -22 0 -5 -13 -37 -28 -71 -16 -34 -36 -93 -45 -132 -9 -38 -24 -104 -34 -145 -13 -60 -17 -121 -17 -300 1 -224 1 -225 36 -365 24 -94 53 -175 87 -247 28 -58 51 -108 51 -112 0 -3 13 -24 28 -48 42 -63 46 -79 22 -85 -11 -3 -20 -9 -20 -14 0 -5 -4 -9 -10 -9 -5 0 -22 -11 -37 -25 -16 -13 -75 -59 -133 -100 -58 -42 -113 -82 -123 -90 -9 -8 -22 -15 -27 -15 -6 0 -10 -6 -10 -13 0 -8 -11 -20 -25 -27 -34 -18 -34 -54 0 -48 14 3 25 2 25 -1 0 -3 -43 -31 -95 -61 -52 -30 -95 -58 -95 -62 0 -5 -5 -8 -11 -8 -19 0 -84 -33 -92 -47 -4 -7 -15 -13 -22 -13 -14 0 -17 -4 -19 -32 -1 -8 15 -15 37 -18 l38 -5 -47 -48 c-56 -59 -54 -81 9 -75 30 3 45 0 54 -11 9 -13 16 -14 43 -4 29 11 30 10 18 -5 -7 -9 -19 -23 -25 -30 -7 -7 -13 -20 -13 -29 0 -12 8 -14 38 -9 20 4 57 8 82 9 25 2 54 8 66 15 18 10 23 8 32 -13 17 -38 86 -35 152 6 27 17 50 34 50 38 0 16 62 30 85 19 33 -15 72 -2 89 30 8 15 31 43 51 62 35 34 38 35 118 35 77 0 85 2 126 33 24 17 52 32 61 32 9 0 42 18 73 40 30 22 61 40 69 40 21 0 88 -26 100 -38 7 -7 17 -12 24 -12 7 0 35 -11 62 -25 66 -33 263 -84 387 -101 189 -25 372 -12 574 41 106 27 130 37 261 97 41 20 80 37 85 39 6 2 51 31 100 64 166 111 405 372 489 534 10 20 22 43 27 51 5 8 12 22 15 30 3 8 17 40 31 70 54 115 95 313 108 520 13 200 -43 480 -134 672 -28 58 -51 108 -51 112 0 3 -13 24 -29 48 -15 24 -34 60 -40 80 -19 57 3 142 50 193 10 11 22 49 28 85 6 36 16 67 21 68 18 6 31 53 25 83 -4 18 -17 33 -36 41 -16 7 -29 15 -29 18 1 10 38 50 47 50 5 0 20 11 33 25 18 19 22 31 17 61 -3 20 -14 45 -23 55 -16 18 -16 20 6 44 15 16 21 32 18 49 -3 15 1 34 8 43 32 43 7 73 -46 55 l-30 -11 0 85 c0 74 -2 84 -18 84 -21 0 -53 -33 -103 -104 l-34 -48 -5 74 c-7 102 -35 133 -80 88z m-870 -740 c36 -7 75 -14 88 -16 21 -4 23 -9 16 -37 -3 -18 -14 -43 -24 -57 -10 -14 -20 -35 -24 -46 -4 -12 -16 -32 -27 -45 -12 -13 -37 -49 -56 -79 -20 -30 -52 -73 -72 -96 -53 -60 -114 -133 -156 -189 -21 -27 -44 -54 -52 -58 -7 -4 -13 -14 -13 -22 0 -7 -18 -33 -40 -57 -22 -23 -40 -46 -40 -50 0 -5 -19 -21 -42 -38 -47 -35 -85 -38 -188 -15 -115 25 -173 20 -264 -23 -45 -22 -106 -46 -136 -56 -48 -15 -77 -25 -140 -50 -70 -28 -100 -77 -51 -84 14 -2 34 -10 45 -17 12 -7 53 -16 91 -20 90 -9 131 -22 178 -57 20 -16 52 -35 70 -43 18 -7 40 -22 49 -32 16 -18 15 -22 -24 -88 -23 -39 -47 -74 -53 -80 -7 -5 -23 -26 -36 -45 -26 -39 -92 -113 -207 -232 -4 -4 -37 -36 -73 -71 l-66 -64 -20 41 c-58 119 -105 240 -115 301 -40 244 -35 409 20 595 8 30 21 66 28 80 7 14 24 54 38 89 15 35 35 75 46 89 11 13 20 31 20 38 0 8 3 14 8 14 4 0 16 16 27 36 24 45 221 245 278 281 23 15 44 30 47 33 20 20 138 78 250 123 61 24 167 50 250 61 60 7 302 -1 370 -14z m837 -661 c52 -101 102 -279 106 -379 2 -42 0 -45 -28 -51 -16 -4 -101 -7 -187 -8 -166 -1 -229 10 -271 49 -19 19 -19 19 14 49 22 21 44 31 65 31 41 0 84 34 84 66 0 30 12 55 56 112 19 25 37 65 44 95 11 51 53 111 74 104 6 -2 25 -32 43 -68z m-662 -810 c17 -10 40 -24 53 -30 12 -7 22 -16 22 -20 0 -4 17 -13 38 -19 20 -7 44 -18 52 -24 8 -7 33 -21 55 -31 22 -11 42 -23 45 -26 11 -14 109 -49 164 -58 62 -11 101 -7 126 14 15 14 38 18 78 16 39 -2 26 -41 -49 -146 -78 -109 -85 -118 -186 -219 -61 -61 -239 -189 -281 -203 -17 -5 -73 -29 -104 -44 -187 -92 -605 -103 -791 -21 -42 19 -47 24 -37 41 5 11 28 32 51 48 22 15 51 38 64 51 13 12 28 22 33 22 17 0 242 233 242 250 0 6 5 10 10 10 6 0 10 6 10 14 0 25 50 55 100 62 59 8 56 6 115 83 50 66 74 117 75 162 0 14 7 40 16 57 18 38 52 41 99 11z"/>
|
||||
</g>
|
||||
</svg>
|
Before Width: | Height: | Size: 4.8 KiB |
3
my-blog/static/index.html
Normal file
3
my-blog/static/index.html
Normal file
@@ -0,0 +1,3 @@
|
||||
<!-- OAuth Comment System - Load globally for session management -->
|
||||
<script type="module" crossorigin src="/assets/comment-atproto-MOwlkOMr.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/comment-atproto-B330B6QX.css">
|
@@ -1,31 +0,0 @@
|
||||
[
|
||||
{
|
||||
"categories": [],
|
||||
"contents": "ブログを移行しました。過去のブログはsyui.github.ioにありあます。 gh-pagesからcf-pagesへの移行になります。 自作のailogでbuildしています。 特徴としては、atproto, AIとの連携です。 name: Deploy to Cloudflare Pages on: push: branches: - main workflow_dispatch: jobs: deploy: runs-on: ubuntu-latest permissions: contents: read deployments: write steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Rust uses: actions-rs/toolchain@v1 with: toolchain: stable - name: Build ailog run: cargo build --release - name: Build site with ailog run: | cd my-blog ../target/release/ailog build - name: List public directory run: | ls -la my-blog/public/ - name: Deploy to Cloudflare Pages uses: cloudflare/pages-action@v1 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} projectName: ${{ secrets.CLOUDFLARE_PROJECT_NAME }} directory: my-blog/public gitHubToken: ${{ secrets.GITHUB_TOKEN }} wranglerVersion: '3' url https://syui.pages.dev https://syui.github.io",
|
||||
"description": "ブログを移行しました。過去のブログはsyui.github.ioにありあます。 \n\ngh-pagesからcf-pagesへの移行になります。\n自作のailogでbuildしています。\n特徴としては、atproto, AIとの連携です。\n\nname: Deploy to Cloudflare Pages\n\non:\n push:\n branches:\n - main\n workfl...",
|
||||
"formated_time": "Sat Jun 14, 2025",
|
||||
"href": "https://syui.ai/posts/2025-06-14-blog.html",
|
||||
"tags": [
|
||||
"blog",
|
||||
"cloudflare",
|
||||
"github"
|
||||
],
|
||||
"title": "ブログを移行した",
|
||||
"utc_time": "2025-06-14T00:00:00Z"
|
||||
},
|
||||
{
|
||||
"categories": [],
|
||||
"contents": "rustで静的サイトジェネレータを作りました。ailogといいます。hugoからの移行になります。 ailogは、最初にatproto-comment-system(oauth)とask-AIという機能をつけました。 quick start $ git clone https://git.syui.ai/ai/log $ cd log $ cargo build $ ./target/debug/ailog init my-blog $ ./target/debug/ailog serve my-blog install $ cargo install --path . --- $ export CARGO_HOME="$HOME/.cargo" $ export RUSTUP_HOME="$HOME/.rustup" $ export PATH="$HOME/.cargo/bin:$PATH" --- $ which ailog $ ailog -h build deploy $ cd my-blog $ vim config.toml $ ailog new test $ vim content/posts/`date +"%Y-%m-%d"`.md $ ailog build # publicの中身をweb-serverにdeploy $ cp -rf ./public/* ./web-server/root/ atproto-comment-system example $ cd ./oauth $ npm i $ npm run build $ npm run preview # Production environment variables VITE_APP_HOST=https://example.com VITE_OAUTH_CLIENT_ID=https://example.com/client-metadata.json VITE_OAUTH_REDIRECT_URI=https://example.com/oauth/callback VITE_ADMIN_DID=did:plc:uqzpqmrjnptsxezjx4xuh2mn # Collection names for OAuth app VITE_COLLECTION_COMMENT=ai.syui.log VITE_COLLECTION_USER=ai.syui.log.user VITE_COLLECTION_CHAT=ai.syui.log.chat # Collection names for ailog (backward compatibility) AILOG_COLLECTION_COMMENT=ai.syui.log AILOG_COLLECTION_USER=ai.syui.log.user # API Configuration VITE_BSKY_PUBLIC_API=https://public.api.bsky.app これはailog oauth build my-blogで./my-blog/config.tomlから./oauth/.env.productionが生成されます。 $ ailog oauth build my-blog use 簡単に説明すると、./oauthで生成するのがatproto-comment-systemです。 <script type="module" crossorigin src="/assets/comment-atproto-${hash}}.js"></script> <link rel="stylesheet" crossorigin href="/assets/comment-atproto-${hash}.css"> <section class="comment-section"> <div id="comment-atproto"></div> </section> ただし、oauthであるため、色々と大変です。本番環境(もしくは近い形)でテストを行いましょう。cf, tailscale, ngrokなど。 tunnel: ${hash} credentials-file: ${path}.json ingress: - hostname: example.com service: http://localhost:4173 originRequest: noHappyEyeballs: true - service: http_status:404 # tunnel list, dnsに登録が必要です $ cloudflared tunnel list $ cloudflared tunnel --config cloudflared-config.yml run $ cloudflared tunnel route dns ${uuid} example.com 以下の2つのcollection recordを生成します。ユーザーにはai.syui.logが生成され、ここにコメントが記録されます。それを取得して表示しています。ai.syui.log.userは管理者であるVITE_ADMIN_DID用です。 VITE_COLLECTION_COMMENT=ai.syui.log VITE_COLLECTION_USER=ai.syui.log.user $ ailog auth login $ ailog stream server このコマンドでai.syui.logをjetstreamから監視して、書き込みがあれば、管理者のai.syui.log.userに記録され、そのuser-listに基づいて、コメント一覧を取得します。 つまり、コメント表示のアカウントを手動で設定するか、自動化するか。自動化するならserverでailog stream serverを動かさなければいけません。 ask-AI ask-AIの仕組みは割愛します。後に変更される可能性が高いと思います。 local llm, mcp, atprotoと組み合わせです。 code syntax # comment d=${0:a:h} // This is a comment fn main() { println!("Hello, world!"); } // This is a comment console.log("Hello, world!");",
|
||||
"description": "rustで静的サイトジェネレータを作りました。ailogといいます。hugoからの移行になります。 \nailogは、最初にatproto-comment-system(oauth)とask-AIという機能をつけました。 \nquick start\n$ git clone https://git.syui.ai/ai/log\n$ cd log\n$ cargo build\n$ ./target/debu...",
|
||||
"formated_time": "Thu Jun 12, 2025",
|
||||
"href": "https://syui.ai/posts/2025-06-06-ailog.html",
|
||||
"tags": [
|
||||
"blog",
|
||||
"rust",
|
||||
"mcp",
|
||||
"atp"
|
||||
],
|
||||
"title": "静的サイトジェネレータを作った",
|
||||
"utc_time": "2025-06-12T00:00:00Z"
|
||||
}
|
||||
]
|
@@ -1,295 +0,0 @@
|
||||
/**
|
||||
* Ask AI functionality - Based on original working implementation
|
||||
*/
|
||||
|
||||
// Global variables for AI functionality
|
||||
let aiProfileData = null;
|
||||
|
||||
// Original functions from working implementation
|
||||
function toggleAskAI() {
|
||||
const panel = document.getElementById('askAiPanel');
|
||||
const isVisible = panel.style.display !== 'none';
|
||||
panel.style.display = isVisible ? 'none' : 'block';
|
||||
|
||||
if (!isVisible) {
|
||||
checkAuthenticationStatus();
|
||||
}
|
||||
}
|
||||
|
||||
function checkAuthenticationStatus() {
|
||||
const userSections = document.querySelectorAll('.user-section');
|
||||
const isAuthenticated = userSections.length > 0;
|
||||
|
||||
if (isAuthenticated) {
|
||||
// User is authenticated - show Ask AI UI
|
||||
document.getElementById('authCheck').style.display = 'none';
|
||||
document.getElementById('chatForm').style.display = 'block';
|
||||
document.getElementById('chatHistory').style.display = 'block';
|
||||
|
||||
// Show initial greeting if chat history is empty
|
||||
const chatHistory = document.getElementById('chatHistory');
|
||||
if (chatHistory.children.length === 0) {
|
||||
showInitialGreeting();
|
||||
}
|
||||
|
||||
// Focus on input
|
||||
setTimeout(() => {
|
||||
document.getElementById('aiQuestion').focus();
|
||||
}, 50);
|
||||
} else {
|
||||
// User not authenticated - show auth message
|
||||
document.getElementById('authCheck').style.display = 'block';
|
||||
document.getElementById('chatForm').style.display = 'none';
|
||||
document.getElementById('chatHistory').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function askQuestion() {
|
||||
const question = document.getElementById('aiQuestion').value;
|
||||
if (!question.trim()) return;
|
||||
|
||||
const askButton = document.getElementById('askButton');
|
||||
askButton.disabled = true;
|
||||
askButton.textContent = 'Posting...';
|
||||
|
||||
try {
|
||||
// Add user message to chat
|
||||
addUserMessage(question);
|
||||
|
||||
// Clear input
|
||||
document.getElementById('aiQuestion').value = '';
|
||||
|
||||
// Show loading
|
||||
showLoadingMessage();
|
||||
|
||||
// Post question via OAuth app
|
||||
window.dispatchEvent(new CustomEvent('postAIQuestion', {
|
||||
detail: { question: question }
|
||||
}));
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to ask question:', error);
|
||||
showErrorMessage('Sorry, I encountered an error. Please try again.');
|
||||
} finally {
|
||||
askButton.disabled = false;
|
||||
askButton.textContent = 'Ask';
|
||||
}
|
||||
}
|
||||
|
||||
function addUserMessage(question) {
|
||||
const chatHistory = document.getElementById('chatHistory');
|
||||
const userSection = document.querySelector('.user-section');
|
||||
|
||||
let userAvatar = '👤';
|
||||
let userDisplay = 'You';
|
||||
let userHandle = 'user';
|
||||
|
||||
if (userSection) {
|
||||
const avatarImg = userSection.querySelector('.user-avatar');
|
||||
const displayName = userSection.querySelector('.user-display-name');
|
||||
const handle = userSection.querySelector('.user-handle');
|
||||
|
||||
if (avatarImg && avatarImg.src) {
|
||||
userAvatar = `<img src="${avatarImg.src}" alt="${displayName?.textContent || 'User'}" class="profile-avatar">`;
|
||||
}
|
||||
if (displayName?.textContent) {
|
||||
userDisplay = displayName.textContent;
|
||||
}
|
||||
if (handle?.textContent) {
|
||||
userHandle = handle.textContent.replace('@', '');
|
||||
}
|
||||
}
|
||||
|
||||
const questionDiv = document.createElement('div');
|
||||
questionDiv.className = 'chat-message user-message comment-style';
|
||||
questionDiv.innerHTML = `
|
||||
<div class="message-header">
|
||||
<div class="avatar">${userAvatar}</div>
|
||||
<div class="user-info">
|
||||
<div class="display-name">${userDisplay}</div>
|
||||
<div class="handle">@${userHandle}</div>
|
||||
<div class="timestamp">${new Date().toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-content">${question}</div>
|
||||
`;
|
||||
chatHistory.appendChild(questionDiv);
|
||||
}
|
||||
|
||||
function showLoadingMessage() {
|
||||
const chatHistory = document.getElementById('chatHistory');
|
||||
const loadingDiv = document.createElement('div');
|
||||
loadingDiv.className = 'ai-loading-simple';
|
||||
loadingDiv.innerHTML = `
|
||||
<i class="fas fa-robot"></i>
|
||||
<span>考えています</span>
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
`;
|
||||
chatHistory.appendChild(loadingDiv);
|
||||
}
|
||||
|
||||
function showErrorMessage(message) {
|
||||
const chatHistory = document.getElementById('chatHistory');
|
||||
removeLoadingMessage();
|
||||
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'chat-message error-message comment-style';
|
||||
errorDiv.innerHTML = `
|
||||
<div class="message-header">
|
||||
<div class="avatar">⚠️</div>
|
||||
<div class="user-info">
|
||||
<div class="display-name">System</div>
|
||||
<div class="handle">@system</div>
|
||||
<div class="timestamp">${new Date().toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-content">${message}</div>
|
||||
`;
|
||||
chatHistory.appendChild(errorDiv);
|
||||
}
|
||||
|
||||
function removeLoadingMessage() {
|
||||
const loadingMsg = document.querySelector('.ai-loading-simple');
|
||||
if (loadingMsg) {
|
||||
loadingMsg.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function showInitialGreeting() {
|
||||
if (!aiProfileData) return;
|
||||
|
||||
const chatHistory = document.getElementById('chatHistory');
|
||||
const greetingDiv = document.createElement('div');
|
||||
greetingDiv.className = 'chat-message ai-message comment-style initial-greeting';
|
||||
|
||||
const avatarElement = aiProfileData.avatar
|
||||
? `<img src="${aiProfileData.avatar}" alt="${aiProfileData.displayName}" class="profile-avatar">`
|
||||
: '🤖';
|
||||
|
||||
greetingDiv.innerHTML = `
|
||||
<div class="message-header">
|
||||
<div class="avatar">${avatarElement}</div>
|
||||
<div class="user-info">
|
||||
<div class="display-name">${aiProfileData.displayName}</div>
|
||||
<div class="handle">@${aiProfileData.handle}</div>
|
||||
<div class="timestamp">${new Date().toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-content">
|
||||
Hello! I'm an AI assistant trained on this blog's content. I can answer questions about the articles, provide insights, and help you understand the topics discussed here. What would you like to know?
|
||||
</div>
|
||||
`;
|
||||
chatHistory.appendChild(greetingDiv);
|
||||
}
|
||||
|
||||
function updateAskAIButton() {
|
||||
const button = document.getElementById('askAiButton');
|
||||
if (!button) return;
|
||||
|
||||
// Only update text, never modify the icon
|
||||
if (aiProfileData && aiProfileData.displayName) {
|
||||
const textNode = button.childNodes[2] || button.lastChild;
|
||||
if (textNode && textNode.nodeType === Node.TEXT_NODE) {
|
||||
textNode.textContent = aiProfileData.displayName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleAIResponse(responseData) {
|
||||
const chatHistory = document.getElementById('chatHistory');
|
||||
removeLoadingMessage();
|
||||
|
||||
const aiProfile = responseData.aiProfile;
|
||||
if (!aiProfile || !aiProfile.handle || !aiProfile.displayName) {
|
||||
console.error('AI profile data is missing');
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date(responseData.timestamp || Date.now());
|
||||
const avatarElement = aiProfile.avatar
|
||||
? `<img src="${aiProfile.avatar}" alt="${aiProfile.displayName}" class="profile-avatar">`
|
||||
: '🤖';
|
||||
|
||||
const answerDiv = document.createElement('div');
|
||||
answerDiv.className = 'chat-message ai-message comment-style';
|
||||
answerDiv.innerHTML = `
|
||||
<div class="message-header">
|
||||
<div class="avatar">${avatarElement}</div>
|
||||
<div class="user-info">
|
||||
<div class="display-name">${aiProfile.displayName}</div>
|
||||
<div class="handle">@${aiProfile.handle}</div>
|
||||
<div class="timestamp">${timestamp.toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-content">${responseData.answer}</div>
|
||||
`;
|
||||
chatHistory.appendChild(answerDiv);
|
||||
|
||||
// Limit chat history
|
||||
limitChatHistory();
|
||||
}
|
||||
|
||||
function limitChatHistory() {
|
||||
const chatHistory = document.getElementById('chatHistory');
|
||||
if (chatHistory.children.length > 10) {
|
||||
chatHistory.removeChild(chatHistory.children[0]);
|
||||
if (chatHistory.children.length > 0) {
|
||||
chatHistory.removeChild(chatHistory.children[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Event listeners setup
|
||||
function setupAskAIEventListeners() {
|
||||
// Listen for AI profile updates from OAuth app
|
||||
window.addEventListener('aiProfileLoaded', function(event) {
|
||||
aiProfileData = event.detail;
|
||||
console.log('AI profile loaded:', aiProfileData);
|
||||
updateAskAIButton();
|
||||
});
|
||||
|
||||
// Listen for AI responses
|
||||
window.addEventListener('aiResponseReceived', function(event) {
|
||||
handleAIResponse(event.detail);
|
||||
});
|
||||
|
||||
// Track IME composition state
|
||||
let isComposing = false;
|
||||
const aiQuestionInput = document.getElementById('aiQuestion');
|
||||
|
||||
if (aiQuestionInput) {
|
||||
aiQuestionInput.addEventListener('compositionstart', function() {
|
||||
isComposing = true;
|
||||
});
|
||||
|
||||
aiQuestionInput.addEventListener('compositionend', function() {
|
||||
isComposing = false;
|
||||
});
|
||||
}
|
||||
|
||||
// Keyboard shortcuts
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
const panel = document.getElementById('askAiPanel');
|
||||
if (panel) {
|
||||
panel.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Enter key to send message (only when not composing Japanese input)
|
||||
if (e.key === 'Enter' && e.target.id === 'aiQuestion' && !e.shiftKey && !isComposing) {
|
||||
e.preventDefault();
|
||||
askQuestion();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize Ask AI when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
setupAskAIEventListeners();
|
||||
console.log('Ask AI initialized successfully');
|
||||
});
|
||||
|
||||
// Global functions for onclick handlers
|
||||
window.toggleAskAI = toggleAskAI;
|
||||
window.askQuestion = askQuestion;
|
@@ -1,94 +0,0 @@
|
||||
/**
|
||||
* Theme and visual effects - Pure CSS animations, no jQuery
|
||||
*/
|
||||
class Theme {
|
||||
constructor() {
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.setupParticleColors();
|
||||
this.setupLogoAnimations();
|
||||
}
|
||||
|
||||
setupParticleColors() {
|
||||
// Dynamic particle colors based on theme
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
/* Dynamic particle colors based on theme */
|
||||
.likeButton .particleLayer circle:nth-child(1),
|
||||
.likeButton .particleLayer circle:nth-child(2) {
|
||||
fill: var(--particle-color-1) !important;
|
||||
}
|
||||
|
||||
.likeButton .particleLayer circle:nth-child(3),
|
||||
.likeButton .particleLayer circle:nth-child(4) {
|
||||
fill: var(--particle-color-2) !important;
|
||||
}
|
||||
|
||||
.likeButton .particleLayer circle:nth-child(5),
|
||||
.likeButton .particleLayer circle:nth-child(6),
|
||||
.likeButton .particleLayer circle:nth-child(7) {
|
||||
fill: var(--particle-color-3) !important;
|
||||
}
|
||||
|
||||
.likeButton .particleLayer circle:nth-child(8),
|
||||
.likeButton .particleLayer circle:nth-child(9),
|
||||
.likeButton .particleLayer circle:nth-child(10) {
|
||||
fill: var(--particle-color-4) !important;
|
||||
}
|
||||
|
||||
.likeButton .particleLayer circle:nth-child(11),
|
||||
.likeButton .particleLayer circle:nth-child(12),
|
||||
.likeButton .particleLayer circle:nth-child(13),
|
||||
.likeButton .particleLayer circle:nth-child(14) {
|
||||
fill: var(--particle-color-5) !important;
|
||||
}
|
||||
|
||||
/* Reset initial animations but allow hover */
|
||||
.likeButton .syui {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.likeButton .particleLayer {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.likeButton .explosion {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* Enable hover animations from package */
|
||||
.likeButton:hover .syui,
|
||||
.likeButton:hover path.syui {
|
||||
animation: syuiDeluxeAnime 400ms forwards !important;
|
||||
}
|
||||
|
||||
.likeButton:hover .particleLayer {
|
||||
animation: particleLayerAnime 800ms forwards !important;
|
||||
}
|
||||
|
||||
.likeButton:hover .explosion {
|
||||
animation: explosionAnime 800ms forwards !important;
|
||||
}
|
||||
|
||||
/* Logo positioning */
|
||||
.logo .likeButton {
|
||||
background: transparent !important;
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
setupLogoAnimations() {
|
||||
// Pure CSS animations are handled by the svg-animation-package.css
|
||||
// This method is reserved for any future JavaScript-based enhancements
|
||||
console.log('Logo animations initialized (CSS-based)');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize theme when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
new Theme();
|
||||
});
|
@@ -1,165 +0,0 @@
|
||||
Fonticons, Inc. (https://fontawesome.com)
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
Font Awesome Free License
|
||||
|
||||
Font Awesome Free is free, open source, and GPL friendly. You can use it for
|
||||
commercial projects, open source projects, or really almost whatever you want.
|
||||
Full Font Awesome Free license: https://fontawesome.com/license/free.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
# Icons: CC BY 4.0 License (https://creativecommons.org/licenses/by/4.0/)
|
||||
|
||||
The Font Awesome Free download is licensed under a Creative Commons
|
||||
Attribution 4.0 International License and applies to all icons packaged
|
||||
as SVG and JS file types.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
# Fonts: SIL OFL 1.1 License
|
||||
|
||||
In the Font Awesome Free download, the SIL OFL license applies to all icons
|
||||
packaged as web and desktop font files.
|
||||
|
||||
Copyright (c) 2024 Fonticons, Inc. (https://fontawesome.com)
|
||||
with Reserved Font Name: "Font Awesome".
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
SIL OPEN FONT LICENSE
|
||||
Version 1.1 - 26 February 2007
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting — in part or in whole — any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
# Code: MIT License (https://opensource.org/licenses/MIT)
|
||||
|
||||
In the Font Awesome Free download, the MIT license applies to all non-font and
|
||||
non-icon files.
|
||||
|
||||
Copyright 2024 Fonticons, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in the
|
||||
Software without restriction, including without limitation the rights to use, copy,
|
||||
modify, merge, publish, distribute, sublicense, and/or sell copies of the Software,
|
||||
and to permit persons to whom the Software is furnished to do so, subject to the
|
||||
following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
||||
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
||||
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
||||
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
# Attribution
|
||||
|
||||
Attribution is required by MIT, SIL OFL, and CC BY licenses. Downloaded Font
|
||||
Awesome Free files already contain embedded comments with sufficient
|
||||
attribution, so you shouldn't need to do anything additional when using these
|
||||
files normally.
|
||||
|
||||
We've kept attribution comments terse, so we ask that you do not actively work
|
||||
to remove them from files, especially code. They're a great way for folks to
|
||||
learn about Font Awesome.
|
||||
|
||||
--------------------------------------------------------------------------------
|
||||
|
||||
# Brand Icons
|
||||
|
||||
All brand icons are trademarks of their respective owners. The use of these
|
||||
trademarks does not indicate endorsement of the trademark holder by Font
|
||||
Awesome, nor vice versa. **Please do not use brand logos for any purpose except
|
||||
to represent the company, product, or service to which they refer.**
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,6 +0,0 @@
|
||||
/*!
|
||||
* Font Awesome Free 6.5.2 by @fontawesome - https://fontawesome.com
|
||||
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
|
||||
* Copyright 2024 Fonticons, Inc.
|
||||
*/
|
||||
:host,:root{--fa-style-family-classic:"Font Awesome 6 Free";--fa-font-regular:normal 400 1em/1 "Font Awesome 6 Free"}@font-face{font-family:"Font Awesome 6 Free";font-style:normal;font-weight:400;font-display:block;src:url(../webfonts/fa-regular-400.woff2) format("woff2"),url(../webfonts/fa-regular-400.ttf) format("truetype")}.fa-regular,.far{font-weight:400}
|
@@ -1,6 +0,0 @@
|
||||
/*!
|
||||
* Font Awesome Free 6.5.2 by @fontawesome - https://fontawesome.com
|
||||
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
|
||||
* Copyright 2024 Fonticons, Inc.
|
||||
*/
|
||||
:host,:root{--fa-style-family-classic:"Font Awesome 6 Free";--fa-font-solid:normal 900 1em/1 "Font Awesome 6 Free"}@font-face{font-family:"Font Awesome 6 Free";font-style:normal;font-weight:900;font-display:block;src:url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.ttf) format("truetype")}.fa-solid,.fas{font-weight:900}
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
Before Width: | Height: | Size: 58 KiB |
Binary file not shown.
Binary file not shown.
@@ -1,99 +0,0 @@
|
||||
@font-face {
|
||||
font-family: 'icomoon';
|
||||
src: url('fonts/icomoon.eot?mxezzh');
|
||||
src: url('fonts/icomoon.eot?mxezzh#iefix') format('embedded-opentype'),
|
||||
url('fonts/icomoon.ttf?mxezzh') format('truetype'),
|
||||
url('fonts/icomoon.woff?mxezzh') format('woff'),
|
||||
url('fonts/icomoon.svg?mxezzh#icomoon') format('svg');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: block;
|
||||
}
|
||||
|
||||
[class^="icon-"], [class*=" icon-"] {
|
||||
/* use !important to prevent issues with browser extensions that change fonts */
|
||||
font-family: 'icomoon' !important;
|
||||
speak: never;
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
font-variant: normal;
|
||||
text-transform: none;
|
||||
line-height: 1;
|
||||
|
||||
/* Better Font Rendering =========== */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.icon-git:before {
|
||||
content: "\e901";
|
||||
}
|
||||
.icon-cube:before {
|
||||
content: "\e900";
|
||||
}
|
||||
.icon-game:before {
|
||||
content: "\e9d5";
|
||||
}
|
||||
.icon-card:before {
|
||||
content: "\e9d6";
|
||||
}
|
||||
.icon-book:before {
|
||||
content: "\e9d7";
|
||||
}
|
||||
.icon-git1:before {
|
||||
content: "\e9d3";
|
||||
}
|
||||
.icon-moji_a:before {
|
||||
content: "\e9c3";
|
||||
}
|
||||
.icon-archlinux:before {
|
||||
content: "\e9c4";
|
||||
}
|
||||
.icon-archlinuxjp:before {
|
||||
content: "\e9c5";
|
||||
}
|
||||
.icon-syui:before {
|
||||
content: "\e9c6";
|
||||
}
|
||||
.icon-phoenix-power:before {
|
||||
content: "\e9c7";
|
||||
}
|
||||
.icon-phoenix-world:before {
|
||||
content: "\e9c8";
|
||||
}
|
||||
.icon-power:before {
|
||||
content: "\e9c9";
|
||||
}
|
||||
.icon-phoenix:before {
|
||||
content: "\e9ca";
|
||||
}
|
||||
.icon-honeycomb:before {
|
||||
content: "\e9cb";
|
||||
}
|
||||
.icon-ai:before {
|
||||
content: "\e9cc";
|
||||
}
|
||||
.icon-robot:before {
|
||||
content: "\e9cd";
|
||||
}
|
||||
.icon-sandar:before {
|
||||
content: "\e9ce";
|
||||
}
|
||||
.icon-moon:before {
|
||||
content: "\e9cf";
|
||||
}
|
||||
.icon-home:before {
|
||||
content: "\e9d0";
|
||||
}
|
||||
.icon-cloud:before {
|
||||
content: "\e9d1";
|
||||
}
|
||||
.icon-api:before {
|
||||
content: "\e9d2";
|
||||
}
|
||||
.icon-aibadge:before {
|
||||
content: "\ebf8";
|
||||
}
|
||||
.icon-aiterm:before {
|
||||
content: "\ebf7";
|
||||
}
|
@@ -1,24 +0,0 @@
|
||||
<svg width="77pt" height="77pt" viewBox="0 0 512 512" class="likeButton" >
|
||||
<circle class="explosion" r="150" cx="250" cy="250"></circle>
|
||||
<g class="particleLayer">
|
||||
<circle fill="#ef454aba" cx="130" cy="126.5" r="12.5"/>
|
||||
<circle fill="#ef454acc" cx="411" cy="313.5" r="12.5"/>
|
||||
<circle fill="#ef454aba" cx="279" cy="86.5" r="12.5"/>
|
||||
<circle fill="#ef454aba" cx="155" cy="390.5" r="12.5"/>
|
||||
<circle fill="#ef454aba" cx="89" cy="292.5" r="10.5"/>
|
||||
<circle fill="#ef454aba" cx="414" cy="282.5" r="10.5"/>
|
||||
<circle fill="#ef454a91" cx="115" cy="149.5" r="10.5"/>
|
||||
<circle fill="#ef454aba" cx="250" cy="80.5" r="10.5"/>
|
||||
<circle fill="#ef454aba" cx="78" cy="261.5" r="10.5"/>
|
||||
<circle fill="#ef454a91" cx="182" cy="402.5" r="10.5"/>
|
||||
<circle fill="#ef454aba" cx="401.5" cy="166" r="13"/>
|
||||
<circle fill="#ef454aba" cx="379" cy="141.5" r="10.5"/>
|
||||
<circle fill="#ef454a91" cx="327" cy="397.5" r="10.5"/>
|
||||
<circle fill="#ef454aba" cx="296" cy="392.5" r="10.5"/>
|
||||
</g>
|
||||
<g transform="translate(0,512) scale(0.1,-0.1)" fill="#000000" class="icon_syui">
|
||||
|
||||
<path class="syui" d="M3660 4460 c-11 -11 -33 -47 -48 -80 l-29 -60 -12 38 c-27 88 -58 92 -98 11 -35 -70 -73 -159 -73 -169 0 -6 -5 -10 -10 -10 -6 0 -15 -10 -21 -22 -33 -73 -52 -92 -47 -48 2 26 -1 35 -14 38 -16 3 -168 -121 -168 -138 0 -5 -13 -16 -28 -24 -24 -13 -35 -12 -87 0 -221 55 -231 56 -480 56 -219 1 -247 -1 -320 -22 -44 -12 -96 -26 -115 -30 -57 -13 -122 -39 -200 -82 -8 -4 -31 -14 -50 -23 -41 -17 -34 -13 -146 -90 -87 -59 -292 -252 -351 -330 -63 -83 -143 -209 -143 -225 0 -10 -7 -23 -15 -30 -8 -7 -15 -17 -15 -22 0 -5 -13 -37 -28 -71 -16 -34 -36 -93 -45 -132 -9 -38 -24 -104 -34 -145 -13 -60 -17 -121 -17 -300 1 -224 1 -225 36 -365 24 -94 53 -175 87 -247 28 -58 51 -108 51 -112 0 -3 13 -24 28 -48 42 -63 46 -79 22 -85 -11 -3 -20 -9 -20 -14 0 -5 -4 -9 -10 -9 -5 0 -22 -11 -37 -25 -16 -13 -75 -59 -133 -100 -58 -42 -113 -82 -123 -90 -9 -8 -22 -15 -27 -15 -6 0 -10 -6 -10 -13 0 -8 -11 -20 -25 -27 -34 -18 -34 -54 0 -48 14 3 25 2 25 -1 0 -3 -43 -31 -95 -61 -52 -30 -95 -58 -95 -62 0 -5 -5 -8 -11 -8 -19 0 -84 -33 -92 -47 -4 -7 -15 -13 -22 -13 -14 0 -17 -4 -19 -32 -1 -8 15 -15 37 -18 l38 -5 -47 -48 c-56 -59 -54 -81 9 -75 30 3 45 0 54 -11 9 -13 16 -14 43 -4 29 11 30 10 18 -5 -7 -9 -19 -23 -25 -30 -7 -7 -13 -20 -13 -29 0 -12 8 -14 38 -9 20 4 57 8 82 9 25 2 54 8 66 15 18 10 23 8 32 -13 17 -38 86 -35 152 6 27 17 50 34 50 38 0 16 62 30 85 19 33 -15 72 -2 89 30 8 15 31 43 51 62 35 34 38 35 118 35 77 0 85 2 126 33 24 17 52 32 61 32 9 0 42 18 73 40 30 22 61 40 69 40 21 0 88 -26 100 -38 7 -7 17 -12 24 -12 7 0 35 -11 62 -25 66 -33 263 -84 387 -101 189 -25 372 -12 574 41 106 27 130 37 261 97 41 20 80 37 85 39 6 2 51 31 100 64 166 111 405 372 489 534 10 20 22 43 27 51 5 8 12 22 15 30 3 8 17 40 31 70 54 115 95 313 108 520 13 200 -43 480 -134 672 -28 58 -51 108 -51 112 0 3 -13 24 -29 48 -15 24 -34 60 -40 80 -19 57 3 142 50 193 10 11 22 49 28 85 6 36 16 67 21 68 18 6 31 53 25 83 -4 18 -17 33 -36 41 -16 7 -29 15 -29 18 1 10 38 50 47 50 5 0 20 11 33 25 18 19 22 31 17 61 -3 20 -14 45 -23 55 -16 18 -16 20 6 44 15 16 21 32 18 49 -3 15 1 34 8 43 32 43 7 73 -46 55 l-30 -11 0 85 c0 74 -2 84 -18 84 -21 0 -53 -33 -103 -104 l-34 -48 -5 74 c-7 102 -35 133 -80 88z m-870 -740 c36 -7 75 -14 88 -16 21 -4 23 -9 16 -37 -3 -18 -14 -43 -24 -57 -10 -14 -20 -35 -24 -46 -4 -12 -16 -32 -27 -45 -12 -13 -37 -49 -56 -79 -20 -30 -52 -73 -72 -96 -53 -60 -114 -133 -156 -189 -21 -27 -44 -54 -52 -58 -7 -4 -13 -14 -13 -22 0 -7 -18 -33 -40 -57 -22 -23 -40 -46 -40 -50 0 -5 -19 -21 -42 -38 -47 -35 -85 -38 -188 -15 -115 25 -173 20 -264 -23 -45 -22 -106 -46 -136 -56 -48 -15 -77 -25 -140 -50 -70 -28 -100 -77 -51 -84 14 -2 34 -10 45 -17 12 -7 53 -16 91 -20 90 -9 131 -22 178 -57 20 -16 52 -35 70 -43 18 -7 40 -22 49 -32 16 -18 15 -22 -24 -88 -23 -39 -47 -74 -53 -80 -7 -5 -23 -26 -36 -45 -26 -39 -92 -113 -207 -232 -4 -4 -37 -36 -73 -71 l-66 -64 -20 41 c-58 119 -105 240 -115 301 -40 244 -35 409 20 595 8 30 21 66 28 80 7 14 24 54 38 89 15 35 35 75 46 89 11 13 20 31 20 38 0 8 3 14 8 14 4 0 16 16 27 36 24 45 221 245 278 281 23 15 44 30 47 33 20 20 138 78 250 123 61 24 167 50 250 61 60 7 302 -1 370 -14z m837 -661 c52 -101 102 -279 106 -379 2 -42 0 -45 -28 -51 -16 -4 -101 -7 -187 -8 -166 -1 -229 10 -271 49 -19 19 -19 19 14 49 22 21 44 31 65 31 41 0 84 34 84 66 0 30 12 55 56 112 19 25 37 65 44 95 11 51 53 111 74 104 6 -2 25 -32 43 -68z m-662 -810 c17 -10 40 -24 53 -30 12 -7 22 -16 22 -20 0 -4 17 -13 38 -19 20 -7 44 -18 52 -24 8 -7 33 -21 55 -31 22 -11 42 -23 45 -26 11 -14 109 -49 164 -58 62 -11 101 -7 126 14 15 14 38 18 78 16 39 -2 26 -41 -49 -146 -78 -109 -85 -118 -186 -219 -61 -61 -239 -189 -281 -203 -17 -5 -73 -29 -104 -44 -187 -92 -605 -103 -791 -21 -42 19 -47 24 -37 41 5 11 28 32 51 48 22 15 51 38 64 51 13 12 28 22 33 22 17 0 242 233 242 250 0 6 5 10 10 10 6 0 10 6 10 14 0 25 50 55 100 62 59 8 56 6 115 83 50 66 74 117 75 162 0 14 7 40 16 57 18 38 52 41 99 11z"/>
|
||||
</g>
|
||||
|
||||
</svg>
|
Before Width: | Height: | Size: 4.8 KiB |
@@ -4,17 +4,10 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}{{ config.title }}{% endblock %}</title>
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
|
||||
<!-- Stylesheets -->
|
||||
<link rel="stylesheet" href="/css/style.css">
|
||||
<link rel="stylesheet" href="/css/svg-animation-package.css">
|
||||
<link rel="stylesheet" href="/pkg/icomoon/style.css">
|
||||
<link rel="stylesheet" href="/pkg/font-awesome/css/all.min.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
|
||||
{% include "oauth-assets.html" %}
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
@@ -22,53 +15,31 @@
|
||||
<header class="main-header">
|
||||
<div class="header-content">
|
||||
<h1><a href="/" class="site-title">{{ config.title }}</a></h1>
|
||||
<div class="logo">
|
||||
<a href="/">
|
||||
<svg width="77pt" height="77pt" viewBox="0 0 512 512" class="likeButton">
|
||||
<circle class="explosion" r="150" cx="250" cy="250"></circle>
|
||||
<g class="particleLayer">
|
||||
<circle fill="#8CE8C3" cx="130" cy="126.5" r="12.5"></circle>
|
||||
<circle fill="#8CE8C3" cx="411" cy="313.5" r="12.5"></circle>
|
||||
<circle fill="#91D2FA" cx="279" cy="86.5" r="12.5"></circle>
|
||||
<circle fill="#91D2FA" cx="155" cy="390.5" r="12.5"></circle>
|
||||
<circle fill="#CC8EF5" cx="89" cy="292.5" r="10.5"></circle>
|
||||
<circle fill="#9BDFBA" cx="414" cy="282.5" r="10.5"></circle>
|
||||
<circle fill="#9BDFBA" cx="115" cy="149.5" r="10.5"></circle>
|
||||
<circle fill="#9FC7FA" cx="250" cy="80.5" r="10.5"></circle>
|
||||
<circle fill="#9FC7FA" cx="78" cy="261.5" r="10.5"></circle>
|
||||
<circle fill="#96D8E9" cx="182" cy="402.5" r="10.5"></circle>
|
||||
<circle fill="#CC8EF5" cx="401.5" cy="166" r="13"></circle>
|
||||
<circle fill="#DB92D0" cx="379" cy="141.5" r="10.5"></circle>
|
||||
<circle fill="#DB92D0" cx="327" cy="397.5" r="10.5"></circle>
|
||||
<circle fill="#DD99B8" cx="296" cy="392.5" r="10.5"></circle>
|
||||
</g>
|
||||
<g transform="translate(0,512) scale(0.1,-0.1)" fill="#000000" class="icon_syui">
|
||||
<path class="syui" d="M3660 4460 c-11 -11 -33 -47 -48 -80 l-29 -60 -12 38 c-27 88 -58 92 -98 11 -35 -70 -73 -159 -73 -169 0 -6 -5 -10 -10 -10 -6 0 -15 -10 -21 -22 -33 -73 -52 -92 -47 -48 2 26 -1 35 -14 38 -16 3 -168 -121 -168 -138 0 -5 -13 -16 -28 -24 -24 -13 -35 -12 -87 0 -221 55 -231 56 -480 56 -219 1 -247 -1 -320 -22 -44 -12 -96 -26 -115 -30 -57 -13 -122 -39 -200 -82 -8 -4 -31 -14 -50 -23 -41 -17 -34 -13 -146 -90 -87 -59 -292 -252 -351 -330 -63 -83 -143 -209 -143 -225 0 -10 -7 -23 -15 -30 -8 -7 -15 -17 -15 -22 0 -5 -13 -37 -28 -71 -16 -34 -36 -93 -45 -132 -9 -38 -24 -104 -34 -145 -13 -60 -17 -121 -17 -300 1 -224 1 -225 36 -365 24 -94 53 -175 87 -247 28 -58 51 -108 51 -112 0 -3 13 -24 28 -48 42 -63 46 -79 22 -85 -11 -3 -20 -9 -20 -14 0 -5 -4 -9 -10 -9 -5 0 -22 -11 -37 -25 -16 -13 -75 -59 -133 -100 -58 -42 -113 -82 -123 -90 -9 -8 -22 -15 -27 -15 -6 0 -10 -6 -10 -13 0 -8 -11 -20 -25 -27 -34 -18 -34 -54 0 -48 14 3 25 2 25 -1 0 -3 -43 -31 -95 -61 -52 -30 -95 -58 -95 -62 0 -5 -5 -8 -11 -8 -19 0 -84 -33 -92 -47 -4 -7 -15 -13 -22 -13 -14 0 -17 -4 -19 -32 -1 -8 15 -15 37 -18 l38 -5 -47 -48 c-56 -59 -54 -81 9 -75 30 3 45 0 54 -11 9 -13 16 -14 43 -4 29 11 30 10 18 -5 -7 -9 -19 -23 -25 -30 -7 -7 -13 -20 -13 -29 0 -12 8 -14 38 -9 20 4 57 8 82 9 25 2 54 8 66 15 18 10 23 8 32 -13 17 -38 86 -35 152 6 27 17 50 34 50 38 0 16 62 30 85 19 33 -15 72 -2 89 30 8 15 31 43 51 62 35 34 38 35 118 35 77 0 85 2 126 33 24 17 52 32 61 32 9 0 42 18 73 40 30 22 61 40 69 40 21 0 88 -26 100 -38 7 -7 17 -12 24 -12 7 0 35 -11 62 -25 66 -33 263 -84 387 -101 189 -25 372 -12 574 41 106 27 130 37 261 97 41 20 80 37 85 39 6 2 51 31 100 64 166 111 405 372 489 534 10 20 22 43 27 51 5 8 12 22 15 30 3 8 17 40 31 70 54 115 95 313 108 520 13 200 -43 480 -134 672 -28 58 -51 108 -51 112 0 3 -13 24 -29 48 -15 24 -34 60 -40 80 -19 57 3 142 50 193 10 11 22 49 28 85 6 36 16 67 21 68 18 6 31 53 25 83 -4 18 -17 33 -36 41 -16 7 -29 15 -29 18 1 10 38 50 47 50 5 0 20 11 33 25 18 19 22 31 17 61 -3 20 -14 45 -23 55 -16 18 -16 20 6 44 15 16 21 32 18 49 -3 15 1 34 8 43 32 43 7 73 -46 55 l-30 -11 0 85 c0 74 -2 84 -18 84 -21 0 -53 -33 -103 -104 l-34 -48 -5 74 c-7 102 -35 133 -80 88z m-870 -740 c36 -7 75 -14 88 -16 21 -4 23 -9 16 -37 -3 -18 -14 -43 -24 -57 -10 -14 -20 -35 -24 -46 -4 -12 -16 -32 -27 -45 -12 -13 -37 -49 -56 -79 -20 -30 -52 -73 -72 -96 -53 -60 -114 -133 -156 -189 -21 -27 -44 -54 -52 -58 -7 -4 -13 -14 -13 -22 0 -7 -18 -33 -40 -57 -22 -23 -40 -46 -40 -50 0 -5 -19 -21 -42 -38 -47 -35 -85 -38 -188 -15 -115 25 -173 20 -264 -23 -45 -22 -106 -46 -136 -56 -48 -15 -77 -25 -140 -50 -70 -28 -100 -77 -51 -84 14 -2 34 -10 45 -17 12 -7 53 -16 91 -20 90 -9 131 -22 178 -57 20 -16 52 -35 70 -43 18 -7 40 -22 49 -32 16 -18 15 -22 -24 -88 -23 -39 -47 -74 -53 -80 -7 -5 -23 -26 -36 -45 -26 -39 -92 -113 -207 -232 -4 -4 -37 -36 -73 -71 l-66 -64 -20 41 c-58 119 -105 240 -115 301 -40 244 -35 409 20 595 8 30 21 66 28 80 7 14 24 54 38 89 15 35 35 75 46 89 11 13 20 31 20 38 0 8 3 14 8 14 4 0 16 16 27 36 24 45 221 245 278 281 23 15 44 30 47 33 20 20 138 78 250 123 61 24 167 50 250 61 60 7 302 -1 370 -14z m837 -661 c52 -101 102 -279 106 -379 2 -42 0 -45 -28 -51 -16 -4 -101 -7 -187 -8 -166 -1 -229 10 -271 49 -19 19 -19 19 14 49 22 21 44 31 65 31 41 0 84 34 84 66 0 30 12 55 56 112 19 25 37 65 44 95 11 51 53 111 74 104 6 -2 25 -32 43 -68z m-662 -810 c17 -10 40 -24 53 -30 12 -7 22 -16 22 -20 0 -4 17 -13 38 -19 20 -7 44 -18 52 -24 8 -7 33 -21 55 -31 22 -11 42 -23 45 -26 11 -14 109 -49 164 -58 62 -11 101 -7 126 14 15 14 38 18 78 16 39 -2 26 -41 -49 -146 -78 -109 -85 -118 -186 -219 -61 -61 -239 -189 -281 -203 -17 -5 -73 -29 -104 -44 -187 -92 -605 -103 -791 -21 -42 19 -47 24 -37 41 5 11 28 32 51 48 22 15 51 38 64 51 13 12 28 22 33 22 17 0 242 233 242 250 0 6 5 10 10 10 6 0 10 6 10 14 0 25 50 55 100 62 59 8 56 6 115 83 50 66 74 117 75 162 0 14 7 40 16 57 18 38 52 41 99 11z"></path>
|
||||
</g>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<!-- Ask AI button on all pages -->
|
||||
<button class="ask-ai-btn" onclick="toggleAskAI()" id="askAiButton">
|
||||
<span class="ai-icon icon-ai"></span>
|
||||
ai
|
||||
<span class="ai-icon">🤖</span>
|
||||
Ask AI
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Ask AI Panel -->
|
||||
<!-- Ask AI panel on all pages -->
|
||||
<div class="ask-ai-panel" id="askAiPanel" style="display: none;">
|
||||
<div class="ask-ai-content">
|
||||
<!-- Authentication check -->
|
||||
<div id="authCheck" class="auth-check">
|
||||
<p>🔒 Please login with ATProto to use Ask AI feature</p>
|
||||
</div>
|
||||
|
||||
<!-- Chat form (hidden until authenticated) -->
|
||||
<div id="chatForm" class="ask-ai-form" style="display: none;">
|
||||
<input type="text" id="aiQuestion" placeholder="What would you like to know?" />
|
||||
<button onclick="askQuestion()" id="askButton">Ask</button>
|
||||
</div>
|
||||
|
||||
<!-- Chat history -->
|
||||
<div id="chatHistory" class="chat-history" style="display: none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -81,17 +52,309 @@
|
||||
</div>
|
||||
|
||||
<footer class="main-footer">
|
||||
<div class="footer-social">
|
||||
<a href="https://syu.is/syui" target="_blank"><i class="fab fa-bluesky"></i></a>
|
||||
<a href="https://git.syui.ai/ai" target="_blank"><span class="icon-ai"></span></a>
|
||||
<a href="https://git.syui.ai/syui" target="_blank"><span class="icon-git"></span></a>
|
||||
</div>
|
||||
<p>© {{ config.author }}</p>
|
||||
<p>© {{ config.author }}</p>
|
||||
</footer>
|
||||
|
||||
<script src="/js/ask-ai.js"></script>
|
||||
<script src="/js/theme.js"></script>
|
||||
|
||||
{% include "oauth-assets.html" %}
|
||||
<script>
|
||||
function toggleAskAI() {
|
||||
const panel = document.getElementById('askAiPanel');
|
||||
const isVisible = panel.style.display !== 'none';
|
||||
panel.style.display = isVisible ? 'none' : 'block';
|
||||
|
||||
if (!isVisible) {
|
||||
checkAuthenticationStatus();
|
||||
}
|
||||
}
|
||||
|
||||
function checkAuthenticationStatus() {
|
||||
const userSections = document.querySelectorAll('.user-section');
|
||||
const isAuthenticated = userSections.length > 0;
|
||||
|
||||
if (isAuthenticated) {
|
||||
// User is authenticated - show Ask AI UI
|
||||
document.getElementById('authCheck').style.display = 'none';
|
||||
document.getElementById('chatForm').style.display = 'block';
|
||||
document.getElementById('chatHistory').style.display = 'block';
|
||||
|
||||
// Show initial greeting if chat history is empty
|
||||
const chatHistory = document.getElementById('chatHistory');
|
||||
if (chatHistory.children.length === 0) {
|
||||
showInitialGreeting();
|
||||
}
|
||||
|
||||
// Focus after a small delay to ensure element is visible
|
||||
setTimeout(() => {
|
||||
document.getElementById('aiQuestion').focus();
|
||||
}, 50);
|
||||
} else {
|
||||
// User is not authenticated - show login message only
|
||||
document.getElementById('authCheck').style.display = 'block';
|
||||
document.getElementById('chatForm').style.display = 'none';
|
||||
document.getElementById('chatHistory').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
let isAIChatReady = false;
|
||||
let aiProfileData = null;
|
||||
|
||||
// Listen for AI ready signal
|
||||
window.addEventListener('aiChatReady', function() {
|
||||
isAIChatReady = true;
|
||||
console.log('AI Chat is ready');
|
||||
});
|
||||
|
||||
|
||||
// Listen for AI profile updates from OAuth app
|
||||
window.addEventListener('aiProfileLoaded', function(event) {
|
||||
aiProfileData = event.detail;
|
||||
console.log('AI profile loaded:', aiProfileData);
|
||||
updateAskAIButton();
|
||||
});
|
||||
|
||||
function updateAskAIButton() {
|
||||
const button = document.getElementById('askAiButton');
|
||||
const iconSpan = button.querySelector('.ai-icon');
|
||||
|
||||
if (aiProfileData && aiProfileData.avatar) {
|
||||
iconSpan.innerHTML = `<img src="${aiProfileData.avatar}" alt="${aiProfileData.displayName || 'AI'}" class="ai-avatar-small">`;
|
||||
}
|
||||
|
||||
if (aiProfileData && aiProfileData.displayName) {
|
||||
button.childNodes[2].textContent = `Ask ${aiProfileData.displayName}`;
|
||||
}
|
||||
}
|
||||
|
||||
function showInitialGreeting() {
|
||||
const chatHistory = document.getElementById('chatHistory');
|
||||
const greetingDiv = document.createElement('div');
|
||||
greetingDiv.className = 'chat-message ai-message comment-style initial-greeting';
|
||||
|
||||
if (!aiProfileData) {
|
||||
return; // Don't show greeting if no AI profile data
|
||||
}
|
||||
|
||||
let avatarElement = '🤖';
|
||||
if (aiProfileData.avatar) {
|
||||
avatarElement = `<img src="${aiProfileData.avatar}" alt="${aiProfileData.displayName}" class="profile-avatar">`;
|
||||
}
|
||||
|
||||
const displayName = aiProfileData.displayName;
|
||||
const handle = aiProfileData.handle;
|
||||
|
||||
greetingDiv.innerHTML = `
|
||||
<div class="message-header">
|
||||
<div class="avatar">${avatarElement}</div>
|
||||
<div class="user-info">
|
||||
<div class="display-name">${displayName}</div>
|
||||
<div class="handle">@${handle}</div>
|
||||
<div class="timestamp">${new Date().toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-content">
|
||||
Hello! I'm an AI assistant trained on this blog's content. I can answer questions about the articles, provide insights, and help you understand the topics discussed here. What would you like to know?
|
||||
</div>
|
||||
`;
|
||||
chatHistory.appendChild(greetingDiv);
|
||||
}
|
||||
|
||||
async function askQuestion() {
|
||||
const question = document.getElementById('aiQuestion').value;
|
||||
const chatHistory = document.getElementById('chatHistory');
|
||||
const askButton = document.getElementById('askButton');
|
||||
|
||||
if (!question.trim()) return;
|
||||
|
||||
// Wait for AI to be ready
|
||||
if (!isAIChatReady) {
|
||||
console.log('Waiting for AI Chat to be ready...');
|
||||
await new Promise(resolve => {
|
||||
const checkReady = setInterval(() => {
|
||||
if (isAIChatReady) {
|
||||
clearInterval(checkReady);
|
||||
resolve();
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
// Disable button and show loading
|
||||
askButton.disabled = true;
|
||||
askButton.textContent = 'Posting...';
|
||||
|
||||
// Get user info from OAuth component
|
||||
const userSection = document.querySelector('.user-section');
|
||||
let userAvatar = '👤';
|
||||
let userDisplay = 'You';
|
||||
let userHandle = 'user';
|
||||
|
||||
if (userSection) {
|
||||
const avatarImg = userSection.querySelector('.user-avatar');
|
||||
const displayName = userSection.querySelector('.user-display-name');
|
||||
const handle = userSection.querySelector('.user-handle');
|
||||
|
||||
if (avatarImg && avatarImg.src) {
|
||||
userAvatar = `<img src="${avatarImg.src}" alt="${displayName?.textContent || 'User'}" class="profile-avatar">`;
|
||||
}
|
||||
if (displayName?.textContent) {
|
||||
userDisplay = displayName.textContent;
|
||||
}
|
||||
if (handle?.textContent) {
|
||||
userHandle = handle.textContent.replace('@', '');
|
||||
}
|
||||
}
|
||||
|
||||
// Add question to chat history in comment style
|
||||
const questionDiv = document.createElement('div');
|
||||
questionDiv.className = 'chat-message user-message comment-style';
|
||||
questionDiv.innerHTML = `
|
||||
<div class="message-header">
|
||||
<div class="avatar">${userAvatar}</div>
|
||||
<div class="user-info">
|
||||
<div class="display-name">${userDisplay}</div>
|
||||
<div class="handle">@${userHandle}</div>
|
||||
<div class="timestamp">${new Date().toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-content">${question}</div>
|
||||
`;
|
||||
chatHistory.appendChild(questionDiv);
|
||||
|
||||
// Clear input
|
||||
document.getElementById('aiQuestion').value = '';
|
||||
|
||||
try {
|
||||
// Show loading immediately
|
||||
const loadingDiv = document.createElement('div');
|
||||
loadingDiv.className = 'ai-loading-simple';
|
||||
loadingDiv.innerHTML = `
|
||||
<i class="fas fa-robot"></i>
|
||||
<span>考えています</span>
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
`;
|
||||
chatHistory.appendChild(loadingDiv);
|
||||
|
||||
// Post question to ATProto via OAuth app
|
||||
const event = new CustomEvent('postAIQuestion', {
|
||||
detail: { question: question }
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
|
||||
} catch (error) {
|
||||
// Remove loading indicator and show error
|
||||
const loadingMsg = chatHistory.querySelector('.ai-loading-simple');
|
||||
if (loadingMsg) {
|
||||
loadingMsg.remove();
|
||||
}
|
||||
|
||||
const errorDiv = document.createElement('div');
|
||||
errorDiv.className = 'chat-message error-message comment-style';
|
||||
errorDiv.innerHTML = `
|
||||
<div class="message-header">
|
||||
<div class="avatar">⚠️</div>
|
||||
<div class="user-info">
|
||||
<div class="display-name">System</div>
|
||||
<div class="handle">@system</div>
|
||||
<div class="timestamp">${new Date().toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-content">Sorry, I encountered an error. Please try again.</div>
|
||||
`;
|
||||
chatHistory.appendChild(errorDiv);
|
||||
} finally {
|
||||
askButton.disabled = false;
|
||||
askButton.textContent = 'Ask';
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
document.getElementById('askAiPanel').style.display = 'none';
|
||||
}
|
||||
|
||||
// Enter key to send message
|
||||
if (e.key === 'Enter' && e.target.id === 'aiQuestion' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
askQuestion();
|
||||
}
|
||||
});
|
||||
|
||||
// Monitor authentication state changes
|
||||
const authObserver = new MutationObserver(function(mutations) {
|
||||
const userSections = document.querySelectorAll('.user-section');
|
||||
if (userSections.length > 0) {
|
||||
checkAuthenticationStatus();
|
||||
// Stop observing once authenticated
|
||||
authObserver.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
// Start observing for authentication changes
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initial authentication check with slight delay for OAuth component
|
||||
setTimeout(() => {
|
||||
checkAuthenticationStatus();
|
||||
}, 500);
|
||||
|
||||
authObserver.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
});
|
||||
|
||||
// Listen for AI responses from OAuth app
|
||||
window.addEventListener('aiResponseReceived', function(event) {
|
||||
const chatHistory = document.getElementById('chatHistory');
|
||||
const loadingMsg = chatHistory.querySelector('.ai-loading-simple');
|
||||
|
||||
if (loadingMsg) {
|
||||
loadingMsg.remove();
|
||||
}
|
||||
|
||||
const aiProfile = event.detail.aiProfile;
|
||||
if (!aiProfile || !aiProfile.handle || !aiProfile.displayName) {
|
||||
console.error('AI profile data is missing, cannot display response');
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = new Date(event.detail.timestamp || Date.now());
|
||||
|
||||
// Create comment-style AI response
|
||||
const answerDiv = document.createElement('div');
|
||||
answerDiv.className = 'chat-message ai-message comment-style';
|
||||
|
||||
// Prepare avatar
|
||||
let avatarElement = '🤖';
|
||||
if (aiProfile.avatar) {
|
||||
avatarElement = `<img src="${aiProfile.avatar}" alt="${aiProfile.displayName}" class="profile-avatar">`;
|
||||
}
|
||||
|
||||
answerDiv.innerHTML = `
|
||||
<div class="message-header">
|
||||
<div class="avatar">${avatarElement}</div>
|
||||
<div class="user-info">
|
||||
<div class="display-name">${aiProfile.displayName}</div>
|
||||
<div class="handle">@${aiProfile.handle}</div>
|
||||
<div class="timestamp">${timestamp.toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-content">${event.detail.answer}</div>
|
||||
`;
|
||||
chatHistory.appendChild(answerDiv);
|
||||
|
||||
// Auto-expand content instead of scrolling
|
||||
if (chatHistory.children.length > 5) {
|
||||
const oldestMessage = chatHistory.children[0];
|
||||
if (oldestMessage && oldestMessage.classList.contains('user-message')) {
|
||||
// Keep the latest 5 exchanges (10 messages)
|
||||
if (chatHistory.children.length > 10) {
|
||||
chatHistory.removeChild(oldestMessage);
|
||||
if (chatHistory.children.length > 0) {
|
||||
chatHistory.removeChild(chatHistory.children[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
@@ -20,6 +20,19 @@
|
||||
<a href="{{ post.url }}">{{ post.title }}</a>
|
||||
</h3>
|
||||
|
||||
{% if post.excerpt %}
|
||||
<p class="post-excerpt">{{ post.excerpt }}</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="post-actions">
|
||||
<a href="{{ post.url }}" class="read-more">Read more</a>
|
||||
{% if post.markdown_url %}
|
||||
<a href="{{ post.markdown_url }}" class="view-markdown" title="View Markdown">Markdown</a>
|
||||
{% endif %}
|
||||
{% if post.translation_url %}
|
||||
<a href="{{ post.translation_url }}" class="view-translation" title="View Translation">🌐</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
|
3
my-blog/templates/oauth-assets.html
Normal file
3
my-blog/templates/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-MOwlkOMr.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/comment-atproto-B330B6QX.css">
|
@@ -16,7 +16,7 @@
|
||||
<div class="article-actions">
|
||||
{% if post.markdown_url %}
|
||||
<a href="{{ post.markdown_url }}" class="action-btn markdown-btn" title="View Markdown">
|
||||
.md
|
||||
📝 Markdown
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if post.translation_url %}
|
||||
|
@@ -16,7 +16,7 @@
|
||||
<div class="article-actions">
|
||||
{% if post.markdown_url %}
|
||||
<a href="{{ post.markdown_url }}" class="action-btn markdown-btn" title="View Markdown">
|
||||
.md
|
||||
📝 Markdown
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if post.translation_url %}
|
||||
|
@@ -16,7 +16,7 @@
|
||||
<div class="article-actions">
|
||||
{% if post.markdown_url %}
|
||||
<a href="{{ post.markdown_url }}" class="action-btn markdown-btn" title="View Markdown">
|
||||
.md
|
||||
Markdown
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if post.translation_url %}
|
||||
|
@@ -2,20 +2,26 @@
|
||||
VITE_APP_HOST=https://syui.ai
|
||||
VITE_OAUTH_CLIENT_ID=https://syui.ai/client-metadata.json
|
||||
VITE_OAUTH_REDIRECT_URI=https://syui.ai/oauth/callback
|
||||
VITE_ADMIN_DID=did:plc:uqzpqmrjnptsxezjx4xuh2mn
|
||||
|
||||
# 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"]
|
||||
# Collection names for OAuth app
|
||||
VITE_COLLECTION_COMMENT=ai.syui.log
|
||||
VITE_COLLECTION_USER=ai.syui.log.user
|
||||
VITE_COLLECTION_CHAT=ai.syui.log.chat
|
||||
|
||||
# Collection names for ailog (backward compatibility)
|
||||
AILOG_COLLECTION_COMMENT=ai.syui.log
|
||||
AILOG_COLLECTION_USER=ai.syui.log.user
|
||||
AILOG_COLLECTION_CHAT=ai.syui.log.chat
|
||||
|
||||
# AI Configuration
|
||||
VITE_AI_ENABLED=true
|
||||
VITE_AI_ASK_AI=true
|
||||
VITE_AI_PROVIDER=ollama
|
||||
VITE_AI_MODEL=gemma3:1b
|
||||
VITE_AI_MODEL=gemma3:2b
|
||||
VITE_AI_HOST=https://ollama.syui.ai
|
||||
VITE_AI_SYSTEM_PROMPT="あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"
|
||||
VITE_AI_SYSTEM_PROMPT="you are a helpful ai assistant trained on this blog's content. you can answer questions about the articles, provide insights, and help users understand the topics discussed."
|
||||
VITE_AI_DID=did:plc:4hqjfn7m6n5hno3doamuhgef
|
||||
|
||||
# API Configuration
|
||||
VITE_BSKY_PUBLIC_API=https://public.api.bsky.app
|
||||
|
@@ -1,15 +1,13 @@
|
||||
{
|
||||
"name": "aicard",
|
||||
"version": "0.1.1",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite --mode development",
|
||||
"build": "vite build --mode production",
|
||||
"build:dev": "vite build --mode development",
|
||||
"build:local": "VITE_APP_HOST=http://localhost:4173 vite build --mode development",
|
||||
"preview": "npm run test:console && vite preview",
|
||||
"test": "vitest",
|
||||
"test:console": "node -r esbuild-register src/tests/console-test.ts"
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.15.12",
|
||||
@@ -28,9 +26,6 @@
|
||||
"@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"
|
||||
"vite": "^5.0.10"
|
||||
}
|
||||
}
|
||||
|
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"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",
|
||||
"client_id": "https://log.syui.ai/client-metadata.json",
|
||||
"client_name": "ai.card",
|
||||
"client_uri": "https://log.syui.ai",
|
||||
"logo_uri": "https://log.syui.ai/favicon.ico",
|
||||
"tos_uri": "https://log.syui.ai/terms",
|
||||
"policy_uri": "https://log.syui.ai/privacy",
|
||||
"redirect_uris": [
|
||||
"https://syui.ai/oauth/callback",
|
||||
"https://syui.ai/"
|
||||
"https://log.syui.ai/oauth/callback",
|
||||
"https://log.syui.ai/"
|
||||
],
|
||||
"response_types": [
|
||||
"code"
|
||||
@@ -21,4 +21,4 @@
|
||||
"subject_type": "public",
|
||||
"application_type": "web",
|
||||
"dpop_bound_access_tokens": true
|
||||
}
|
||||
}
|
@@ -162,76 +162,11 @@
|
||||
}
|
||||
|
||||
.app-main {
|
||||
max-width: 1000px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
* {
|
||||
max-width: 100% !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
.app .app-main {
|
||||
max-width: 100% !important;
|
||||
margin: 0 !important;
|
||||
padding: 0px !important;
|
||||
}
|
||||
|
||||
.comment-item {
|
||||
padding: 0px !important;
|
||||
margin: 0px !important;
|
||||
}
|
||||
|
||||
.auth-section {
|
||||
padding: 0px !important;
|
||||
}
|
||||
|
||||
.comments-list {
|
||||
padding: 0px !important;
|
||||
}
|
||||
|
||||
.comment-section {
|
||||
padding: 30px 0 !important;
|
||||
margin: 0px !important;
|
||||
}
|
||||
|
||||
.comment-content {
|
||||
padding: 10px !important;
|
||||
word-wrap: break-word !important;
|
||||
overflow-wrap: break-word !important;
|
||||
white-space: pre-wrap !important;
|
||||
}
|
||||
|
||||
.comment-header {
|
||||
padding: 10px !important;
|
||||
}
|
||||
|
||||
/* Fix overflow on article pages */
|
||||
article.article-content {
|
||||
overflow-x: hidden !important;
|
||||
}
|
||||
|
||||
/* Ensure full width on mobile */
|
||||
.app {
|
||||
max-width: 100vw !important;
|
||||
overflow-x: hidden !important;
|
||||
}
|
||||
|
||||
/* Fix button overflow */
|
||||
button {
|
||||
max-width: 100%;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
/* Fix comment-meta URI overflow */
|
||||
.comment-meta {
|
||||
word-break: break-all !important;
|
||||
overflow-wrap: break-word !important;
|
||||
}
|
||||
}
|
||||
|
||||
.gacha-section {
|
||||
text-align: center;
|
||||
margin-bottom: 60px;
|
||||
@@ -329,18 +264,9 @@
|
||||
.comment-section {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
/* padding: 20px; - removed to avoid double padding */
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.comment-section {
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.auth-section {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #e9ecef;
|
||||
@@ -350,38 +276,6 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.auth-section.search-bar-layout {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.auth-section.search-bar-layout .handle-input {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
padding: 10px 15px;
|
||||
font-size: 16px;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 6px 0 0 6px;
|
||||
background: white;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.auth-section.search-bar-layout .handle-input:focus {
|
||||
border-color: var(--theme-color);
|
||||
}
|
||||
|
||||
.auth-section.search-bar-layout .atproto-button {
|
||||
margin: 0;
|
||||
padding: 10px 20px;
|
||||
border-radius: 0 6px 6px 0;
|
||||
min-width: 50px;
|
||||
font-weight: bold;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.atproto-button {
|
||||
background: var(--theme-color);
|
||||
color: var(--white);
|
||||
@@ -415,30 +309,6 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Override for search bar layout */
|
||||
.search-bar-layout .handle-input {
|
||||
width: auto;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Mobile responsive for search bar */
|
||||
@media (max-width: 480px) {
|
||||
.auth-section.search-bar-layout {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.auth-section.search-bar-layout .handle-input {
|
||||
width: 100%;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.auth-section.search-bar-layout .atproto-button {
|
||||
width: 100%;
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.auth-hint {
|
||||
color: #6c757d;
|
||||
font-size: 14px;
|
||||
@@ -571,8 +441,9 @@
|
||||
}
|
||||
|
||||
.comments-list {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 0px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.comments-header {
|
||||
@@ -683,8 +554,6 @@
|
||||
line-height: 1.5;
|
||||
color: #333;
|
||||
margin-bottom: 10px;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.comment-meta {
|
||||
@@ -931,6 +800,28 @@
|
||||
background: #f6f8fa;
|
||||
}
|
||||
|
||||
/* AI Chat History */
|
||||
.ai-chat-list {
|
||||
max-width: 100%;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.chat-item {
|
||||
border: 1px solid #d1d9e0;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.chat-actions {
|
||||
display: flex;
|
||||
@@ -981,8 +872,4 @@
|
||||
padding: 40px 20px;
|
||||
color: #656d76;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.chat-message.comment-style {
|
||||
border-left: 4px solid var(--theme-color);
|
||||
}
|
||||
}
|
1013
oauth/src/App.tsx
1013
oauth/src/App.tsx
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,7 @@ const response = await fetch(`${aiConfig.host}/api/generate`, {
|
||||
options: {
|
||||
temperature: 0.9,
|
||||
top_p: 0.9,
|
||||
num_predict: 200,
|
||||
num_predict: 80,
|
||||
repeat_penalty: 1.1,
|
||||
}
|
||||
}),
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { User } from '../services/auth';
|
||||
import { atprotoOAuthService } from '../services/atproto-oauth';
|
||||
import { appConfig, getCollectionNames } from '../config/app';
|
||||
import { appConfig } from '../config/app';
|
||||
|
||||
interface AIChatProps {
|
||||
user: User | null;
|
||||
@@ -14,22 +14,26 @@ export const AIChat: React.FC<AIChatProps> = ({ user, isEnabled }) => {
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [aiProfile, setAiProfile] = useState<any>(null);
|
||||
|
||||
// Get AI settings from appConfig (unified configuration)
|
||||
// Get AI settings from environment variables
|
||||
const aiConfig = {
|
||||
enabled: appConfig.aiEnabled,
|
||||
askAi: appConfig.aiAskAi,
|
||||
provider: appConfig.aiProvider,
|
||||
model: appConfig.aiModel,
|
||||
host: appConfig.aiHost,
|
||||
systemPrompt: appConfig.aiSystemPrompt,
|
||||
aiDid: appConfig.aiDid,
|
||||
bskyPublicApi: appConfig.bskyPublicApi,
|
||||
enabled: import.meta.env.VITE_AI_ENABLED === 'true',
|
||||
askAi: import.meta.env.VITE_AI_ASK_AI === 'true',
|
||||
provider: import.meta.env.VITE_AI_PROVIDER || 'ollama',
|
||||
model: import.meta.env.VITE_AI_MODEL || 'gemma3:4b',
|
||||
host: import.meta.env.VITE_AI_HOST || 'https://ollama.syui.ai',
|
||||
systemPrompt: import.meta.env.VITE_AI_SYSTEM_PROMPT || 'You are a helpful AI assistant trained on this blog\'s content.',
|
||||
aiDid: import.meta.env.VITE_AI_DID || 'did:plc:uqzpqmrjnptsxezjx4xuh2mn',
|
||||
bskyPublicApi: import.meta.env.VITE_BSKY_PUBLIC_API || 'https://public.api.bsky.app',
|
||||
};
|
||||
|
||||
// Fetch AI profile on load
|
||||
useEffect(() => {
|
||||
const fetchAIProfile = async () => {
|
||||
console.log('=== AI PROFILE FETCH START ===');
|
||||
console.log('AI DID:', aiConfig.aiDid);
|
||||
|
||||
if (!aiConfig.aiDid) {
|
||||
console.log('No AI DID configured');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -37,7 +41,9 @@ export const AIChat: React.FC<AIChatProps> = ({ user, isEnabled }) => {
|
||||
// Try with agent first
|
||||
const agent = atprotoOAuthService.getAgent();
|
||||
if (agent) {
|
||||
console.log('Fetching AI profile with agent for DID:', aiConfig.aiDid);
|
||||
const profile = await agent.getProfile({ actor: aiConfig.aiDid });
|
||||
console.log('AI profile fetched successfully:', profile.data);
|
||||
const profileData = {
|
||||
did: aiConfig.aiDid,
|
||||
handle: profile.data.handle,
|
||||
@@ -45,17 +51,21 @@ export const AIChat: React.FC<AIChatProps> = ({ user, isEnabled }) => {
|
||||
avatar: profile.data.avatar,
|
||||
description: profile.data.description
|
||||
};
|
||||
console.log('Setting aiProfile to:', profileData);
|
||||
setAiProfile(profileData);
|
||||
|
||||
// Dispatch event to update Ask AI button
|
||||
window.dispatchEvent(new CustomEvent('aiProfileLoaded', { detail: profileData }));
|
||||
console.log('=== AI PROFILE FETCH SUCCESS (AGENT) ===');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback to public API
|
||||
console.log('No agent available, trying public API for AI profile');
|
||||
const response = await fetch(`${aiConfig.bskyPublicApi}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(aiConfig.aiDid)}`);
|
||||
if (response.ok) {
|
||||
const profileData = await response.json();
|
||||
console.log('AI profile fetched via public API:', profileData);
|
||||
const profile = {
|
||||
did: aiConfig.aiDid,
|
||||
handle: profileData.handle,
|
||||
@@ -63,15 +73,21 @@ export const AIChat: React.FC<AIChatProps> = ({ user, isEnabled }) => {
|
||||
avatar: profileData.avatar,
|
||||
description: profileData.description
|
||||
};
|
||||
console.log('Setting aiProfile to:', profile);
|
||||
setAiProfile(profile);
|
||||
|
||||
// Dispatch event to update Ask AI button
|
||||
window.dispatchEvent(new CustomEvent('aiProfileLoaded', { detail: profile }));
|
||||
console.log('=== AI PROFILE FETCH SUCCESS (PUBLIC API) ===');
|
||||
return;
|
||||
} else {
|
||||
console.error('Public API failed with status:', response.status);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch AI profile:', error);
|
||||
setAiProfile(null);
|
||||
}
|
||||
console.log('=== AI PROFILE FETCH FAILED ===');
|
||||
};
|
||||
|
||||
fetchAIProfile();
|
||||
@@ -84,6 +100,9 @@ export const AIChat: React.FC<AIChatProps> = ({ user, isEnabled }) => {
|
||||
const handleAIQuestion = async (event: any) => {
|
||||
if (!user || !event.detail || !event.detail.question || isProcessing || !aiProfile) return;
|
||||
|
||||
console.log('AIChat received question:', event.detail.question);
|
||||
console.log('Current aiProfile state:', aiProfile);
|
||||
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
await postQuestionAndGenerateResponse(event.detail.question);
|
||||
@@ -95,6 +114,7 @@ export const AIChat: React.FC<AIChatProps> = ({ user, isEnabled }) => {
|
||||
// Add listener with a small delay to ensure it's ready
|
||||
setTimeout(() => {
|
||||
window.addEventListener('postAIQuestion', handleAIQuestion);
|
||||
console.log('AIChat event listener registered');
|
||||
|
||||
// Notify that AI is ready
|
||||
window.dispatchEvent(new CustomEvent('aiChatReady'));
|
||||
@@ -114,50 +134,40 @@ export const AIChat: React.FC<AIChatProps> = ({ user, isEnabled }) => {
|
||||
const agent = atprotoOAuthService.getAgent();
|
||||
if (!agent) throw new Error('No agent available');
|
||||
|
||||
// Get collection names
|
||||
const collections = getCollectionNames(appConfig.collections.base);
|
||||
|
||||
// 1. Post question to ATProto
|
||||
const now = new Date();
|
||||
const rkey = now.toISOString().replace(/[:.]/g, '-');
|
||||
|
||||
// Extract post metadata from current page
|
||||
const currentUrl = window.location.href;
|
||||
const postSlug = currentUrl.match(/\/posts\/([^/]+)/)?.[1] || '';
|
||||
const postTitle = document.title.replace(' - syui.ai', '') || '';
|
||||
|
||||
const questionRecord = {
|
||||
$type: collections.chat,
|
||||
post: {
|
||||
url: currentUrl,
|
||||
slug: postSlug,
|
||||
title: postTitle,
|
||||
date: new Date().toISOString(),
|
||||
tags: [],
|
||||
language: "ja"
|
||||
},
|
||||
type: "question",
|
||||
text: question,
|
||||
$type: appConfig.collections.chat,
|
||||
question: question,
|
||||
url: window.location.href,
|
||||
createdAt: now.toISOString(),
|
||||
author: {
|
||||
did: user.did,
|
||||
handle: user.handle,
|
||||
avatar: user.avatar,
|
||||
displayName: user.displayName || user.handle,
|
||||
},
|
||||
createdAt: now.toISOString(),
|
||||
context: {
|
||||
page_title: document.title,
|
||||
page_url: window.location.href,
|
||||
},
|
||||
};
|
||||
|
||||
await agent.api.com.atproto.repo.putRecord({
|
||||
repo: user.did,
|
||||
collection: collections.chat,
|
||||
collection: appConfig.collections.chat,
|
||||
rkey: rkey,
|
||||
record: questionRecord,
|
||||
});
|
||||
|
||||
console.log('Question posted to ATProto');
|
||||
|
||||
// 2. Get chat history
|
||||
const chatRecords = await agent.api.com.atproto.repo.listRecords({
|
||||
repo: user.did,
|
||||
collection: collections.chat,
|
||||
collection: appConfig.collections.chat,
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
@@ -165,10 +175,10 @@ export const AIChat: React.FC<AIChatProps> = ({ user, isEnabled }) => {
|
||||
if (chatRecords.data.records) {
|
||||
chatHistoryText = chatRecords.data.records
|
||||
.map((r: any) => {
|
||||
if (r.value.type === 'question') {
|
||||
return `User: ${r.value.text}`;
|
||||
} else if (r.value.type === 'answer') {
|
||||
return `AI: ${r.value.text}`;
|
||||
if (r.value.question) {
|
||||
return `User: ${r.value.question}`;
|
||||
} else if (r.value.answer) {
|
||||
return `AI: ${r.value.answer}`;
|
||||
}
|
||||
return '';
|
||||
})
|
||||
@@ -191,7 +201,6 @@ Answer:`;
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Origin': 'https://syui.ai',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: aiConfig.model,
|
||||
@@ -200,7 +209,7 @@ Answer:`;
|
||||
options: {
|
||||
temperature: 0.9,
|
||||
top_p: 0.9,
|
||||
num_predict: 200, // Longer responses for better answers
|
||||
num_predict: 80, // Shorter responses for faster generation
|
||||
repeat_penalty: 1.1,
|
||||
}
|
||||
}),
|
||||
@@ -226,38 +235,37 @@ Answer:`;
|
||||
// 5. Save AI response in background
|
||||
const answerRkey = now.toISOString().replace(/[:.]/g, '-') + '-answer';
|
||||
|
||||
console.log('=== SAVING AI ANSWER ===');
|
||||
console.log('Current aiProfile:', aiProfile);
|
||||
|
||||
const answerRecord = {
|
||||
$type: collections.chat,
|
||||
post: {
|
||||
url: currentUrl,
|
||||
slug: postSlug,
|
||||
title: postTitle,
|
||||
date: new Date().toISOString(),
|
||||
tags: [],
|
||||
language: "ja"
|
||||
},
|
||||
type: "answer",
|
||||
text: aiAnswer,
|
||||
$type: appConfig.collections.chat,
|
||||
answer: aiAnswer,
|
||||
question_rkey: rkey,
|
||||
url: window.location.href,
|
||||
createdAt: now.toISOString(),
|
||||
author: {
|
||||
did: aiProfile.did,
|
||||
handle: aiProfile.handle,
|
||||
displayName: aiProfile.displayName,
|
||||
avatar: aiProfile.avatar,
|
||||
},
|
||||
createdAt: now.toISOString(),
|
||||
};
|
||||
|
||||
console.log('Answer record to save:', answerRecord);
|
||||
|
||||
// Save to ATProto asynchronously (don't wait for it)
|
||||
agent.api.com.atproto.repo.putRecord({
|
||||
repo: user.did,
|
||||
collection: collections.chat,
|
||||
collection: appConfig.collections.chat,
|
||||
rkey: answerRkey,
|
||||
record: answerRecord,
|
||||
}).catch(err => {
|
||||
// Silent fail for AI response saving
|
||||
console.error('Failed to save AI response to ATProto:', err);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to generate AI response:', error);
|
||||
window.dispatchEvent(new CustomEvent('aiResponseError', {
|
||||
detail: { error: 'AI応答の生成に失敗しました' }
|
||||
}));
|
||||
|
@@ -32,7 +32,7 @@ export const AIProfile: React.FC<AIProfileProps> = ({ aiDid }) => {
|
||||
description: response.data.description,
|
||||
});
|
||||
} catch (error) {
|
||||
// Failed to fetch AI profile
|
||||
console.error('Failed to fetch AI profile:', error);
|
||||
// Fallback to basic info
|
||||
setProfile({
|
||||
did: aiDid,
|
||||
|
@@ -26,7 +26,7 @@ export const CardBox: React.FC<CardBoxProps> = ({ userDid }) => {
|
||||
const data = await atprotoOAuthService.getCardsFromBox();
|
||||
setBoxData(data);
|
||||
} catch (err) {
|
||||
// Failed to load card box
|
||||
console.error('カードボックス読み込みエラー:', err);
|
||||
setError(err instanceof Error ? err.message : 'カードボックスの読み込みに失敗しました');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -52,7 +52,7 @@ export const CardBox: React.FC<CardBoxProps> = ({ userDid }) => {
|
||||
setBoxData({ records: [] });
|
||||
alert('カードボックスを削除しました');
|
||||
} catch (err) {
|
||||
// Failed to delete card box
|
||||
console.error('カードボックス削除エラー:', err);
|
||||
setError(err instanceof Error ? err.message : 'カードボックスの削除に失敗しました');
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
|
@@ -32,7 +32,7 @@ export const CardList: React.FC = () => {
|
||||
const data = await response.json();
|
||||
setMasterData(data);
|
||||
} catch (err) {
|
||||
// Failed to load card master data
|
||||
console.error('Error loading card master data:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to load card data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
@@ -29,7 +29,7 @@ export const CollectionAnalysis: React.FC<CollectionAnalysisProps> = ({ userDid
|
||||
const result = await aiCardApi.analyzeCollection(userDid);
|
||||
setAnalysis(result);
|
||||
} catch (err) {
|
||||
// Collection analysis failed
|
||||
console.error('Collection analysis failed:', err);
|
||||
setError('AI分析機能を利用するにはai.gptサーバーが必要です。基本機能はai.cardサーバーのみで利用できます。');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
@@ -48,7 +48,7 @@ export const GachaAnimation: React.FC<GachaAnimationProps> = ({
|
||||
await atprotoOAuthService.saveCardToCollection(card);
|
||||
alert('カードデータをatprotoコレクションに保存しました!');
|
||||
} catch (error) {
|
||||
// Failed to save card
|
||||
console.error('保存エラー:', error);
|
||||
alert('保存に失敗しました。認証が必要かもしれません。');
|
||||
} finally {
|
||||
setIsSharing(false);
|
||||
|
@@ -30,7 +30,7 @@ export const GachaStats: React.FC = () => {
|
||||
try {
|
||||
result = await aiCardApi.getEnhancedStats();
|
||||
} catch (aiError) {
|
||||
// AI stats unavailable, using basic stats
|
||||
console.warn('AI統計が利用できません、基本統計に切り替えます:', aiError);
|
||||
setUseAI(false);
|
||||
result = await cardApi.getGachaStats();
|
||||
}
|
||||
@@ -39,7 +39,7 @@ export const GachaStats: React.FC = () => {
|
||||
}
|
||||
setStats(result);
|
||||
} catch (err) {
|
||||
// Gacha stats failed
|
||||
console.error('Gacha stats failed:', err);
|
||||
setError('統計データの取得に失敗しました。ai.cardサーバーが起動していることを確認してください。');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
@@ -160,7 +160,7 @@ export const Login: React.FC<LoginProps> = ({ onLogin, onClose, defaultHandle })
|
||||
/>
|
||||
<small>
|
||||
メインパスワードではなく、
|
||||
<a href={`${import.meta.env.VITE_ATPROTO_WEB_URL || 'https://bsky.app'}/settings/app-passwords`} target="_blank" rel="noopener noreferrer">
|
||||
<a href="https://bsky.app/settings/app-passwords" target="_blank" rel="noopener noreferrer">
|
||||
アプリパスワード
|
||||
</a>
|
||||
を使用してください
|
||||
|
@@ -7,6 +7,8 @@ interface OAuthCallbackProps {
|
||||
}
|
||||
|
||||
export const OAuthCallback: React.FC<OAuthCallbackProps> = ({ onSuccess, onError }) => {
|
||||
console.log('=== OAUTH CALLBACK COMPONENT MOUNTED ===');
|
||||
console.log('Current URL:', window.location.href);
|
||||
|
||||
const [isProcessing, setIsProcessing] = useState(true);
|
||||
const [needsHandle, setNeedsHandle] = useState(false);
|
||||
@@ -16,10 +18,12 @@ export const OAuthCallback: React.FC<OAuthCallbackProps> = ({ onSuccess, onError
|
||||
useEffect(() => {
|
||||
// Add timeout to prevent infinite loading
|
||||
const timeoutId = setTimeout(() => {
|
||||
console.error('OAuth callback timeout');
|
||||
onError('OAuth認証がタイムアウトしました');
|
||||
}, 10000); // 10 second timeout
|
||||
|
||||
const handleCallback = async () => {
|
||||
console.log('=== HANDLE CALLBACK STARTED ===');
|
||||
try {
|
||||
// Handle both query params (?) and hash params (#)
|
||||
const hashParams = new URLSearchParams(window.location.hash.substring(1));
|
||||
@@ -31,6 +35,14 @@ export const OAuthCallback: React.FC<OAuthCallbackProps> = ({ onSuccess, onError
|
||||
const error = hashParams.get('error') || queryParams.get('error');
|
||||
const iss = hashParams.get('iss') || queryParams.get('iss');
|
||||
|
||||
console.log('OAuth callback parameters:', {
|
||||
code: code ? code.substring(0, 20) + '...' : null,
|
||||
state: state,
|
||||
error: error,
|
||||
iss: iss,
|
||||
hash: window.location.hash,
|
||||
search: window.location.search
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error(`OAuth error: ${error}`);
|
||||
@@ -40,10 +52,12 @@ export const OAuthCallback: React.FC<OAuthCallbackProps> = ({ onSuccess, onError
|
||||
throw new Error('Missing OAuth parameters');
|
||||
}
|
||||
|
||||
console.log('Processing OAuth callback with params:', { code: code?.substring(0, 10) + '...', state, iss });
|
||||
|
||||
// Use the official BrowserOAuthClient to handle the callback
|
||||
const result = await atprotoOAuthService.handleOAuthCallback();
|
||||
if (result) {
|
||||
console.log('OAuth callback completed successfully:', result);
|
||||
|
||||
// Success - notify parent component
|
||||
onSuccess(result.did, result.handle);
|
||||
@@ -52,7 +66,11 @@ export const OAuthCallback: React.FC<OAuthCallbackProps> = ({ onSuccess, onError
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('OAuth callback error:', error);
|
||||
|
||||
// Even if OAuth fails, try to continue with a fallback approach
|
||||
console.warn('OAuth callback failed, attempting fallback...');
|
||||
|
||||
try {
|
||||
// Create a minimal session to allow the user to proceed
|
||||
const fallbackSession = {
|
||||
@@ -64,6 +82,7 @@ export const OAuthCallback: React.FC<OAuthCallbackProps> = ({ onSuccess, onError
|
||||
onSuccess(fallbackSession.did, fallbackSession.handle);
|
||||
|
||||
} catch (fallbackError) {
|
||||
console.error('Fallback also failed:', fallbackError);
|
||||
onError(error instanceof Error ? error.message : 'OAuth認証に失敗しました');
|
||||
}
|
||||
} finally {
|
||||
@@ -85,13 +104,17 @@ export const OAuthCallback: React.FC<OAuthCallbackProps> = ({ onSuccess, onError
|
||||
|
||||
const trimmedHandle = handle.trim();
|
||||
if (!trimmedHandle) {
|
||||
console.log('Handle is empty');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Submitting handle:', trimmedHandle);
|
||||
setIsProcessing(true);
|
||||
|
||||
try {
|
||||
// Resolve DID from handle
|
||||
const did = await atprotoOAuthService.resolveDIDFromHandle(trimmedHandle);
|
||||
console.log('Resolved DID:', did);
|
||||
|
||||
// Update session with resolved DID and handle
|
||||
const updatedSession = {
|
||||
@@ -106,6 +129,7 @@ export const OAuthCallback: React.FC<OAuthCallbackProps> = ({ onSuccess, onError
|
||||
// Success - notify parent component
|
||||
onSuccess(did, trimmedHandle);
|
||||
} catch (error) {
|
||||
console.error('Failed to resolve DID:', error);
|
||||
setIsProcessing(false);
|
||||
onError(error instanceof Error ? error.message : 'ハンドルからDIDの解決に失敗しました');
|
||||
}
|
||||
@@ -125,6 +149,7 @@ export const OAuthCallback: React.FC<OAuthCallbackProps> = ({ onSuccess, onError
|
||||
type="text"
|
||||
value={handle}
|
||||
onChange={(e) => {
|
||||
console.log('Input changed:', e.target.value);
|
||||
setHandle(e.target.value);
|
||||
}}
|
||||
placeholder="例: syui.ai または user.bsky.social"
|
||||
|
@@ -6,9 +6,14 @@ export const OAuthCallbackPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
console.log('=== OAUTH CALLBACK PAGE MOUNTED ===');
|
||||
console.log('Current URL:', window.location.href);
|
||||
console.log('Search params:', window.location.search);
|
||||
console.log('Pathname:', window.location.pathname);
|
||||
}, []);
|
||||
|
||||
const handleSuccess = (did: string, handle: string) => {
|
||||
console.log('OAuth success, redirecting to home:', { did, handle });
|
||||
|
||||
// Add a small delay to ensure state is properly updated
|
||||
setTimeout(() => {
|
||||
@@ -17,6 +22,7 @@ export const OAuthCallbackPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleError = (error: string) => {
|
||||
console.error('OAuth error, redirecting to home:', error);
|
||||
|
||||
// Add a small delay before redirect
|
||||
setTimeout(() => {
|
||||
|
@@ -1,14 +1,10 @@
|
||||
// Application configuration
|
||||
export interface AppConfig {
|
||||
adminDid: string;
|
||||
adminHandle: string;
|
||||
aiDid: string;
|
||||
aiHandle: string;
|
||||
aiDisplayName: string;
|
||||
aiAvatar: string;
|
||||
aiDescription: string;
|
||||
collections: {
|
||||
base: string; // Base collection like "ai.syui.log"
|
||||
comment: string;
|
||||
user: string;
|
||||
chat: string;
|
||||
};
|
||||
host: string;
|
||||
rkey?: string; // Current post rkey if on post page
|
||||
@@ -17,36 +13,13 @@ export interface AppConfig {
|
||||
aiProvider: string;
|
||||
aiModel: string;
|
||||
aiHost: string;
|
||||
aiSystemPrompt: string;
|
||||
allowedHandles: string[]; // Handles allowed for OAuth authentication
|
||||
atprotoPds: string; // Configured PDS for admin/ai handles
|
||||
// Legacy - prefer per-user PDS detection
|
||||
bskyPublicApi: string;
|
||||
atprotoApi: string;
|
||||
}
|
||||
|
||||
// Collection name builders (similar to Rust implementation)
|
||||
export function getCollectionNames(base: string) {
|
||||
if (!base) {
|
||||
// Fallback to default
|
||||
base = 'ai.syui.log';
|
||||
}
|
||||
|
||||
const collections = {
|
||||
comment: base,
|
||||
user: `${base}.user`,
|
||||
chat: `${base}.chat`,
|
||||
chatLang: `${base}.chat.lang`,
|
||||
chatComment: `${base}.chat.comment`,
|
||||
};
|
||||
|
||||
return collections;
|
||||
}
|
||||
|
||||
// Generate collection names from host
|
||||
// Format: ${reg}.${name}.${sub}
|
||||
// Example: log.syui.ai -> ai.syui.log
|
||||
function generateBaseCollectionFromHost(host: string): string {
|
||||
function generateCollectionNames(host: string): { comment: string; user: string; chat: string } {
|
||||
try {
|
||||
// Remove protocol if present
|
||||
const cleanHost = host.replace(/^https?:\/\//, '');
|
||||
@@ -61,50 +34,43 @@ function generateBaseCollectionFromHost(host: string): string {
|
||||
// Reverse the parts for collection naming
|
||||
// log.syui.ai -> ai.syui.log
|
||||
const reversedParts = parts.reverse();
|
||||
const result = reversedParts.join('.');
|
||||
return result;
|
||||
const collectionBase = reversedParts.join('.');
|
||||
|
||||
return {
|
||||
comment: collectionBase,
|
||||
user: `${collectionBase}.user`,
|
||||
chat: `${collectionBase}.chat`
|
||||
};
|
||||
} catch (error) {
|
||||
// Fallback to default
|
||||
return 'ai.syui.log';
|
||||
console.warn('Failed to generate collection names from host:', host, error);
|
||||
// Fallback to default collections
|
||||
return {
|
||||
comment: 'ai.syui.log',
|
||||
user: 'ai.syui.log.user',
|
||||
chat: 'ai.syui.log.chat'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Extract rkey from current URL
|
||||
// /posts/xxx -> xxx (remove .html if present)
|
||||
// /posts/xxx.html -> xxx
|
||||
function extractRkeyFromUrl(): string | undefined {
|
||||
const pathname = window.location.pathname;
|
||||
const match = pathname.match(/\/posts\/([^/]+)\/?$/);
|
||||
if (match) {
|
||||
// Remove .html extension if present
|
||||
return match[1].replace(/\.html$/, '');
|
||||
}
|
||||
return undefined;
|
||||
const match = pathname.match(/\/posts\/([^/]+)\.html$/);
|
||||
return match ? match[1] : undefined;
|
||||
}
|
||||
|
||||
// Get application configuration from environment variables
|
||||
export function getAppConfig(): AppConfig {
|
||||
const host = import.meta.env.VITE_APP_HOST || 'https://log.syui.ai';
|
||||
const adminHandle = import.meta.env.VITE_ADMIN_HANDLE || 'ai.syui.ai';
|
||||
const aiHandle = import.meta.env.VITE_AI_HANDLE || 'ai.syui.ai';
|
||||
|
||||
// DIDsはハンドルから実行時に解決される(フォールバック用のみ保持)
|
||||
const adminDid = import.meta.env.VITE_ADMIN_DID || 'did:plc:uqzpqmrjnptsxezjx4xuh2mn';
|
||||
const aiDid = import.meta.env.VITE_AI_DID || 'did:plc:6qyecktefllvenje24fcxnie';
|
||||
const aiDisplayName = import.meta.env.VITE_AI_DISPLAY_NAME || 'ai';
|
||||
const aiAvatar = import.meta.env.VITE_AI_AVATAR || '';
|
||||
const aiDescription = import.meta.env.VITE_AI_DESCRIPTION || '';
|
||||
|
||||
// Priority: Environment variables > Auto-generated from host
|
||||
const autoGeneratedBase = generateBaseCollectionFromHost(host);
|
||||
let baseCollection = import.meta.env.VITE_OAUTH_COLLECTION || autoGeneratedBase;
|
||||
|
||||
// Ensure base collection is never undefined
|
||||
if (!baseCollection) {
|
||||
baseCollection = 'ai.syui.log';
|
||||
}
|
||||
|
||||
const autoGeneratedCollections = generateCollectionNames(host);
|
||||
const collections = {
|
||||
base: baseCollection,
|
||||
comment: import.meta.env.VITE_COLLECTION_COMMENT || autoGeneratedCollections.comment,
|
||||
user: import.meta.env.VITE_COLLECTION_USER || autoGeneratedCollections.user,
|
||||
chat: import.meta.env.VITE_COLLECTION_CHAT || autoGeneratedCollections.chat,
|
||||
};
|
||||
|
||||
const rkey = extractRkeyFromUrl();
|
||||
@@ -113,31 +79,21 @@ export function getAppConfig(): AppConfig {
|
||||
const aiEnabled = import.meta.env.VITE_AI_ENABLED === 'true';
|
||||
const aiAskAi = import.meta.env.VITE_AI_ASK_AI === 'true';
|
||||
const aiProvider = import.meta.env.VITE_AI_PROVIDER || 'ollama';
|
||||
const aiModel = import.meta.env.VITE_AI_MODEL || 'gemma3:4b';
|
||||
const aiModel = import.meta.env.VITE_AI_MODEL || 'gemma2:2b';
|
||||
const aiHost = import.meta.env.VITE_AI_HOST || 'https://ollama.syui.ai';
|
||||
const aiSystemPrompt = import.meta.env.VITE_AI_SYSTEM_PROMPT || 'You are a helpful AI assistant trained on this blog\'s content.';
|
||||
const atprotoPds = import.meta.env.VITE_ATPROTO_PDS || 'syu.is';
|
||||
const bskyPublicApi = import.meta.env.VITE_BSKY_PUBLIC_API || 'https://public.api.bsky.app';
|
||||
const atprotoApi = import.meta.env.VITE_ATPROTO_API || 'https://bsky.social';
|
||||
|
||||
// Parse allowed handles list
|
||||
const allowedHandlesStr = import.meta.env.VITE_ATPROTO_HANDLE_LIST || '[]';
|
||||
let allowedHandles: string[] = [];
|
||||
try {
|
||||
allowedHandles = JSON.parse(allowedHandlesStr);
|
||||
} catch {
|
||||
// If parsing fails, allow all handles (empty array means no restriction)
|
||||
allowedHandles = [];
|
||||
}
|
||||
console.log('App configuration:', {
|
||||
host,
|
||||
adminDid,
|
||||
collections,
|
||||
rkey: rkey || 'none (not on post page)',
|
||||
ai: { enabled: aiEnabled, askAi: aiAskAi, provider: aiProvider, model: aiModel, host: aiHost },
|
||||
bskyPublicApi
|
||||
});
|
||||
|
||||
return {
|
||||
adminDid,
|
||||
adminHandle,
|
||||
aiDid,
|
||||
aiHandle,
|
||||
aiDisplayName,
|
||||
aiAvatar,
|
||||
aiDescription,
|
||||
collections,
|
||||
host,
|
||||
rkey,
|
||||
@@ -146,11 +102,7 @@ export function getAppConfig(): AppConfig {
|
||||
aiProvider,
|
||||
aiModel,
|
||||
aiHost,
|
||||
aiSystemPrompt,
|
||||
allowedHandles,
|
||||
atprotoPds,
|
||||
bskyPublicApi,
|
||||
atprotoApi
|
||||
bskyPublicApi
|
||||
};
|
||||
}
|
||||
|
||||
|
@@ -12,8 +12,10 @@ import { OAuthEndpointHandler } from './utils/oauth-endpoints'
|
||||
|
||||
// Mount React app to all comment-atproto divs
|
||||
const mountPoints = document.querySelectorAll('#comment-atproto');
|
||||
console.log(`Found ${mountPoints.length} comment-atproto mount points`);
|
||||
|
||||
mountPoints.forEach((mountPoint, index) => {
|
||||
console.log(`Mounting React app to comment-atproto #${index + 1}`);
|
||||
ReactDOM.createRoot(mountPoint as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
|
@@ -73,6 +73,7 @@ export const aiCardApi = {
|
||||
});
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
console.warn('ai.gpt AI分析機能が利用できません:', error);
|
||||
throw new Error('AI分析機能を利用するにはai.gptサーバーが必要です');
|
||||
}
|
||||
},
|
||||
@@ -85,6 +86,7 @@ export const aiCardApi = {
|
||||
const response = await aiGptApi.get('/card_get_gacha_stats');
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
console.warn('ai.gpt AI統計機能が利用できません:', error);
|
||||
throw new Error('AI統計機能を利用するにはai.gptサーバーが必要です');
|
||||
}
|
||||
},
|
||||
|
@@ -12,7 +12,6 @@ interface AtprotoSession {
|
||||
|
||||
class AtprotoOAuthService {
|
||||
private oauthClient: BrowserOAuthClient | null = null;
|
||||
private oauthClientSyuIs: BrowserOAuthClient | null = null;
|
||||
private agent: Agent | null = null;
|
||||
private initializePromise: Promise<void> | null = null;
|
||||
|
||||
@@ -32,50 +31,51 @@ class AtprotoOAuthService {
|
||||
|
||||
private async _doInitialize(): Promise<void> {
|
||||
try {
|
||||
console.log('=== INITIALIZING ATPROTO OAUTH CLIENT ===');
|
||||
|
||||
// Generate client ID based on current origin
|
||||
const clientId = this.getClientId();
|
||||
|
||||
// Initialize both OAuth clients
|
||||
console.log('Client ID:', clientId);
|
||||
|
||||
// Support multiple PDS hosts for OAuth
|
||||
this.oauthClient = await BrowserOAuthClient.load({
|
||||
clientId: clientId,
|
||||
handleResolver: 'https://bsky.social',
|
||||
plcDirectoryUrl: 'https://plc.directory',
|
||||
});
|
||||
|
||||
this.oauthClientSyuIs = await BrowserOAuthClient.load({
|
||||
clientId: clientId,
|
||||
handleResolver: 'https://syu.is',
|
||||
plcDirectoryUrl: 'https://plc.syu.is',
|
||||
handleResolver: 'https://bsky.social', // Default resolver
|
||||
});
|
||||
|
||||
// Try to restore existing session from either client
|
||||
let result = await this.oauthClient.init();
|
||||
if (!result?.session) {
|
||||
result = await this.oauthClientSyuIs.init();
|
||||
}
|
||||
console.log('BrowserOAuthClient initialized successfully with multi-PDS support');
|
||||
|
||||
// Try to restore existing session
|
||||
const result = await this.oauthClient.init();
|
||||
if (result?.session) {
|
||||
console.log('Existing session restored:', {
|
||||
did: result.session.did,
|
||||
handle: result.session.handle || 'unknown',
|
||||
hasAccessJwt: !!result.session.accessJwt,
|
||||
hasRefreshJwt: !!result.session.refreshJwt
|
||||
});
|
||||
|
||||
// Create Agent instance with proper configuration
|
||||
|
||||
console.log('Creating Agent with session:', result.session);
|
||||
|
||||
// Delete the old agent initialization code - we'll create it properly below
|
||||
|
||||
// Set the session after creating the agent
|
||||
// The session object from BrowserOAuthClient appears to be a special object
|
||||
|
||||
|
||||
|
||||
console.log('Full session object:', result.session);
|
||||
console.log('Session type:', typeof result.session);
|
||||
console.log('Session constructor:', result.session?.constructor?.name);
|
||||
|
||||
// Try to iterate over the session object
|
||||
if (result.session) {
|
||||
|
||||
console.log('Session properties:');
|
||||
for (const key in result.session) {
|
||||
|
||||
console.log(` ${key}:`, result.session[key]);
|
||||
}
|
||||
|
||||
// Check if session has methods
|
||||
const methods = Object.getOwnPropertyNames(Object.getPrototypeOf(result.session));
|
||||
|
||||
console.log('Session methods:', methods);
|
||||
}
|
||||
|
||||
// BrowserOAuthClient might return a Session object that needs to be used with the agent
|
||||
@@ -83,28 +83,56 @@ class AtprotoOAuthService {
|
||||
if (result.session) {
|
||||
// Process the session to extract DID and handle
|
||||
const sessionData = await this.processSession(result.session);
|
||||
|
||||
console.log('Session processed during initialization:', sessionData);
|
||||
}
|
||||
|
||||
} else {
|
||||
|
||||
console.log('No existing session found');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
|
||||
console.error('Failed to initialize OAuth client:', error);
|
||||
this.initializePromise = null; // Reset on error to allow retry
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async processSession(session: any): Promise<{ did: string; handle: string }> {
|
||||
console.log('Processing session:', session);
|
||||
|
||||
// Log full session structure
|
||||
console.log('Session structure:');
|
||||
console.log('- sub:', session.sub);
|
||||
console.log('- did:', session.did);
|
||||
console.log('- handle:', session.handle);
|
||||
console.log('- iss:', session.iss);
|
||||
console.log('- aud:', session.aud);
|
||||
|
||||
// Check if agent has properties we can access
|
||||
if (session.agent) {
|
||||
console.log('- agent:', session.agent);
|
||||
console.log('- agent.did:', session.agent?.did);
|
||||
console.log('- agent.handle:', session.agent?.handle);
|
||||
}
|
||||
|
||||
const did = session.sub || session.did;
|
||||
let handle = session.handle || 'unknown';
|
||||
|
||||
// Create Agent directly with session (per official docs)
|
||||
try {
|
||||
this.agent = new Agent(session);
|
||||
console.log('Agent created directly with session');
|
||||
|
||||
// Check if agent has session info after creation
|
||||
console.log('Agent after creation:');
|
||||
console.log('- agent.did:', this.agent.did);
|
||||
console.log('- agent.session:', this.agent.session);
|
||||
if (this.agent.session) {
|
||||
console.log('- agent.session.did:', this.agent.session.did);
|
||||
console.log('- agent.session.handle:', this.agent.session.handle);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('Failed to create Agent with session directly, trying dpopFetch method');
|
||||
// Fallback to dpopFetch method
|
||||
this.agent = new Agent({
|
||||
service: session.server?.serviceEndpoint || 'https://bsky.social',
|
||||
@@ -117,7 +145,7 @@ class AtprotoOAuthService {
|
||||
|
||||
// If handle is missing, try multiple methods to resolve it
|
||||
if (!handle || handle === 'unknown') {
|
||||
|
||||
console.log('Handle not in session, attempting to resolve...');
|
||||
|
||||
// Method 1: Try using the agent to get profile
|
||||
try {
|
||||
@@ -126,11 +154,11 @@ class AtprotoOAuthService {
|
||||
if (profile.data.handle) {
|
||||
handle = profile.data.handle;
|
||||
(this as any)._sessionInfo.handle = handle;
|
||||
|
||||
console.log('Successfully resolved handle via getProfile:', handle);
|
||||
return { did, handle };
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
console.error('getProfile failed:', err);
|
||||
}
|
||||
|
||||
// Method 2: Try using describeRepo
|
||||
@@ -141,20 +169,18 @@ class AtprotoOAuthService {
|
||||
if (repoDesc.data.handle) {
|
||||
handle = repoDesc.data.handle;
|
||||
(this as any)._sessionInfo.handle = handle;
|
||||
|
||||
console.log('Got handle from describeRepo:', handle);
|
||||
return { did, handle };
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
console.error('describeRepo failed:', err);
|
||||
}
|
||||
|
||||
// Method 3: Fallback for admin DID
|
||||
const adminDid = import.meta.env.VITE_ADMIN_DID;
|
||||
if (did === adminDid) {
|
||||
const appHost = import.meta.env.VITE_APP_HOST || 'https://syui.ai';
|
||||
handle = new URL(appHost).hostname;
|
||||
// Method 3: Hardcoded fallback for known DIDs
|
||||
if (did === 'did:plc:uqzpqmrjnptsxezjx4xuh2mn') {
|
||||
handle = 'syui.ai';
|
||||
(this as any)._sessionInfo.handle = handle;
|
||||
|
||||
console.log('Using hardcoded handle for known DID');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +191,7 @@ class AtprotoOAuthService {
|
||||
// Use environment variable if available
|
||||
const envClientId = import.meta.env.VITE_OAUTH_CLIENT_ID;
|
||||
if (envClientId) {
|
||||
|
||||
console.log('Using client ID from environment:', envClientId);
|
||||
return envClientId;
|
||||
}
|
||||
|
||||
@@ -174,7 +200,7 @@ class AtprotoOAuthService {
|
||||
// For localhost development, use undefined for loopback client
|
||||
// The BrowserOAuthClient will handle this automatically
|
||||
if (origin.includes('localhost') || origin.includes('127.0.0.1')) {
|
||||
|
||||
console.log('Using loopback client for localhost development');
|
||||
return undefined as any; // Loopback client
|
||||
}
|
||||
|
||||
@@ -182,15 +208,39 @@ class AtprotoOAuthService {
|
||||
return `${origin}/client-metadata.json`;
|
||||
}
|
||||
|
||||
private detectPDSFromHandle(handle: string): string {
|
||||
console.log('Detecting PDS for handle:', handle);
|
||||
|
||||
// Supported PDS hosts and their corresponding handles
|
||||
const pdsMapping = {
|
||||
'syu.is': 'https://syu.is',
|
||||
'bsky.social': 'https://bsky.social',
|
||||
};
|
||||
|
||||
// Check if handle ends with known PDS domains
|
||||
for (const [domain, pdsUrl] of Object.entries(pdsMapping)) {
|
||||
if (handle.endsWith(`.${domain}`)) {
|
||||
console.log(`Handle ${handle} mapped to PDS: ${pdsUrl}`);
|
||||
return pdsUrl;
|
||||
}
|
||||
}
|
||||
|
||||
// Default to bsky.social
|
||||
console.log(`Handle ${handle} using default PDS: https://bsky.social`);
|
||||
return 'https://bsky.social';
|
||||
}
|
||||
|
||||
async initiateOAuthFlow(handle?: string): Promise<void> {
|
||||
try {
|
||||
if (!this.oauthClient || !this.oauthClientSyuIs) {
|
||||
console.log('=== INITIATING OAUTH FLOW ===');
|
||||
|
||||
if (!this.oauthClient) {
|
||||
console.log('OAuth client not initialized, initializing now...');
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
if (!this.oauthClient || !this.oauthClientSyuIs) {
|
||||
throw new Error('Failed to initialize OAuth clients');
|
||||
if (!this.oauthClient) {
|
||||
throw new Error('Failed to initialize OAuth client');
|
||||
}
|
||||
|
||||
// If handle is not provided, prompt user
|
||||
@@ -201,42 +251,75 @@ class AtprotoOAuthService {
|
||||
}
|
||||
}
|
||||
|
||||
// Determine which OAuth client to use
|
||||
const allowedHandlesStr = import.meta.env.VITE_ATPROTO_HANDLE_LIST || '[]';
|
||||
let allowedHandles: string[] = [];
|
||||
try {
|
||||
allowedHandles = JSON.parse(allowedHandlesStr);
|
||||
} catch {
|
||||
allowedHandles = [];
|
||||
console.log('Starting OAuth flow for handle:', handle);
|
||||
|
||||
// Detect PDS based on handle
|
||||
const pdsUrl = this.detectPDSFromHandle(handle);
|
||||
console.log('Detected PDS for handle:', { handle, pdsUrl });
|
||||
|
||||
// Re-initialize OAuth client with correct PDS if needed
|
||||
if (pdsUrl !== 'https://bsky.social') {
|
||||
console.log('Re-initializing OAuth client for custom PDS:', pdsUrl);
|
||||
this.oauthClient = await BrowserOAuthClient.load({
|
||||
clientId: this.getClientId(),
|
||||
handleResolver: pdsUrl,
|
||||
});
|
||||
}
|
||||
|
||||
const usesSyuIs = handle.endsWith('.syu.is') || allowedHandles.includes(handle);
|
||||
const oauthClient = usesSyuIs ? this.oauthClientSyuIs : this.oauthClient;
|
||||
|
||||
// Start OAuth authorization flow
|
||||
const authUrl = await oauthClient.authorize(handle, {
|
||||
scope: 'atproto transition:generic',
|
||||
});
|
||||
console.log('Calling oauthClient.authorize with handle:', handle);
|
||||
|
||||
// Redirect to authorization server
|
||||
window.location.href = authUrl.toString();
|
||||
try {
|
||||
const authUrl = await this.oauthClient.authorize(handle, {
|
||||
scope: 'atproto transition:generic',
|
||||
});
|
||||
|
||||
console.log('Authorization URL generated:', authUrl.toString());
|
||||
console.log('URL breakdown:', {
|
||||
protocol: authUrl.protocol,
|
||||
hostname: authUrl.hostname,
|
||||
pathname: authUrl.pathname,
|
||||
search: authUrl.search
|
||||
});
|
||||
|
||||
// Store some debug info before redirect
|
||||
sessionStorage.setItem('oauth_debug_pre_redirect', JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
handle: handle,
|
||||
authUrl: authUrl.toString(),
|
||||
currentUrl: window.location.href
|
||||
}));
|
||||
|
||||
// Redirect to authorization server
|
||||
console.log('About to redirect to:', authUrl.toString());
|
||||
window.location.href = authUrl.toString();
|
||||
} catch (authorizeError) {
|
||||
console.error('oauthClient.authorize failed:', authorizeError);
|
||||
console.error('Error details:', {
|
||||
name: authorizeError.name,
|
||||
message: authorizeError.message,
|
||||
stack: authorizeError.stack
|
||||
});
|
||||
throw authorizeError;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to initiate OAuth flow:', error);
|
||||
throw new Error(`OAuth認証の開始に失敗しました: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
async handleOAuthCallback(): Promise<{ did: string; handle: string } | null> {
|
||||
try {
|
||||
|
||||
|
||||
|
||||
|
||||
console.log('=== HANDLING OAUTH CALLBACK ===');
|
||||
console.log('Current URL:', window.location.href);
|
||||
console.log('URL hash:', window.location.hash);
|
||||
console.log('URL search:', window.location.search);
|
||||
|
||||
// BrowserOAuthClient should automatically handle the callback
|
||||
// We just need to initialize it and it will process the current URL
|
||||
if (!this.oauthClient) {
|
||||
|
||||
console.log('OAuth client not initialized, initializing now...');
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
@@ -244,11 +327,11 @@ class AtprotoOAuthService {
|
||||
throw new Error('Failed to initialize OAuth client');
|
||||
}
|
||||
|
||||
|
||||
console.log('OAuth client ready, initializing to process callback...');
|
||||
|
||||
// Call init() again to process the callback URL
|
||||
const result = await this.oauthClient.init();
|
||||
|
||||
console.log('OAuth callback processing result:', result);
|
||||
|
||||
if (result?.session) {
|
||||
// Process the session
|
||||
@@ -256,36 +339,47 @@ class AtprotoOAuthService {
|
||||
}
|
||||
|
||||
// If no session yet, wait a bit and try again
|
||||
|
||||
console.log('No session found immediately, waiting...');
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// Try to check session again
|
||||
const sessionCheck = await this.checkSession();
|
||||
if (sessionCheck) {
|
||||
|
||||
console.log('Session found after delay:', sessionCheck);
|
||||
return sessionCheck;
|
||||
}
|
||||
|
||||
|
||||
console.warn('OAuth callback completed but no session was created');
|
||||
return null;
|
||||
|
||||
} catch (error) {
|
||||
|
||||
console.error('OAuth callback handling failed:', error);
|
||||
console.error('Error details:', {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
throw new Error(`OAuth認証の完了に失敗しました: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async checkSession(): Promise<{ did: string; handle: string } | null> {
|
||||
try {
|
||||
console.log('=== CHECK SESSION CALLED ===');
|
||||
|
||||
if (!this.oauthClient) {
|
||||
console.log('No OAuth client, initializing...');
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
if (!this.oauthClient) {
|
||||
console.log('OAuth client initialization failed');
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log('Running oauthClient.init() to check session...');
|
||||
const result = await this.oauthClient.init();
|
||||
console.log('oauthClient.init() result:', result);
|
||||
|
||||
if (result?.session) {
|
||||
// Use the common session processing method
|
||||
@@ -294,7 +388,7 @@ class AtprotoOAuthService {
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
|
||||
console.error('Session check failed:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -304,7 +398,13 @@ class AtprotoOAuthService {
|
||||
}
|
||||
|
||||
getSession(): AtprotoSession | null {
|
||||
|
||||
console.log('getSession called');
|
||||
console.log('Current state:', {
|
||||
hasAgent: !!this.agent,
|
||||
hasAgentSession: !!this.agent?.session,
|
||||
hasOAuthClient: !!this.oauthClient,
|
||||
hasSessionInfo: !!(this as any)._sessionInfo
|
||||
});
|
||||
|
||||
// First check if we have an agent with session
|
||||
if (this.agent?.session) {
|
||||
@@ -314,7 +414,7 @@ class AtprotoOAuthService {
|
||||
accessJwt: this.agent.session.accessJwt || '',
|
||||
refreshJwt: this.agent.session.refreshJwt || '',
|
||||
};
|
||||
|
||||
console.log('Returning agent session:', session);
|
||||
return session;
|
||||
}
|
||||
|
||||
@@ -326,11 +426,11 @@ class AtprotoOAuthService {
|
||||
accessJwt: 'dpop-protected', // Indicate that tokens are handled by dpopFetch
|
||||
refreshJwt: 'dpop-protected',
|
||||
};
|
||||
|
||||
console.log('Returning stored session info:', session);
|
||||
return session;
|
||||
}
|
||||
|
||||
|
||||
console.log('No session available');
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -350,20 +450,28 @@ class AtprotoOAuthService {
|
||||
|
||||
async logout(): Promise<void> {
|
||||
try {
|
||||
console.log('=== LOGGING OUT ===');
|
||||
|
||||
// Clear Agent
|
||||
this.agent = null;
|
||||
console.log('Agent cleared');
|
||||
|
||||
// Clear BrowserOAuthClient session
|
||||
if (this.oauthClient) {
|
||||
console.log('Clearing OAuth client session...');
|
||||
try {
|
||||
// BrowserOAuthClient may have a revoke or signOut method
|
||||
if (typeof (this.oauthClient as any).signOut === 'function') {
|
||||
await (this.oauthClient as any).signOut();
|
||||
console.log('OAuth client signed out');
|
||||
} else if (typeof (this.oauthClient as any).revoke === 'function') {
|
||||
await (this.oauthClient as any).revoke();
|
||||
console.log('OAuth client revoked');
|
||||
} else {
|
||||
console.log('No explicit signOut method found on OAuth client');
|
||||
}
|
||||
} catch (oauthError) {
|
||||
// Ignore logout errors
|
||||
console.error('OAuth client logout error:', oauthError);
|
||||
}
|
||||
|
||||
// Reset the OAuth client to force re-initialization
|
||||
@@ -375,18 +483,20 @@ class AtprotoOAuthService {
|
||||
localStorage.removeItem('atproto_session');
|
||||
sessionStorage.clear();
|
||||
|
||||
// Clear all OAuth-related storage
|
||||
// Clear all localStorage items that might be related to OAuth
|
||||
const keysToRemove: string[] = [];
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const key = localStorage.key(i);
|
||||
if (key && (key.includes('oauth') || key.includes('atproto') || key.includes('session'))) {
|
||||
localStorage.removeItem(key);
|
||||
keysToRemove.push(key);
|
||||
}
|
||||
}
|
||||
keysToRemove.forEach(key => {
|
||||
console.log('Removing localStorage key:', key);
|
||||
localStorage.removeItem(key);
|
||||
});
|
||||
|
||||
// Clear internal session info
|
||||
(this as any)._sessionInfo = null;
|
||||
|
||||
|
||||
console.log('=== LOGOUT COMPLETED ===');
|
||||
|
||||
// Force page reload to ensure clean state
|
||||
setTimeout(() => {
|
||||
@@ -394,7 +504,7 @@ class AtprotoOAuthService {
|
||||
}, 100);
|
||||
|
||||
} catch (error) {
|
||||
|
||||
console.error('Logout failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -409,8 +519,8 @@ class AtprotoOAuthService {
|
||||
const did = sessionInfo.did;
|
||||
|
||||
try {
|
||||
|
||||
|
||||
console.log('Saving cards to atproto collection...');
|
||||
console.log('Using DID:', did);
|
||||
|
||||
// Ensure we have a fresh agent
|
||||
if (!this.agent) {
|
||||
@@ -440,6 +550,13 @@ class AtprotoOAuthService {
|
||||
createdAt: createdAt
|
||||
};
|
||||
|
||||
console.log('PutRecord request:', {
|
||||
repo: did,
|
||||
collection: collection,
|
||||
rkey: rkey,
|
||||
record: record
|
||||
});
|
||||
|
||||
|
||||
// Use Agent's com.atproto.repo.putRecord method
|
||||
const response = await this.agent.com.atproto.repo.putRecord({
|
||||
@@ -449,9 +566,9 @@ class AtprotoOAuthService {
|
||||
record: record
|
||||
});
|
||||
|
||||
|
||||
console.log('カードデータをai.card.boxに保存しました:', response);
|
||||
} catch (error) {
|
||||
|
||||
console.error('カードボックス保存エラー:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -467,8 +584,8 @@ class AtprotoOAuthService {
|
||||
const did = sessionInfo.did;
|
||||
|
||||
try {
|
||||
|
||||
|
||||
console.log('Fetching cards from atproto collection...');
|
||||
console.log('Using DID:', did);
|
||||
|
||||
// Ensure we have a fresh agent
|
||||
if (!this.agent) {
|
||||
@@ -481,7 +598,7 @@ class AtprotoOAuthService {
|
||||
rkey: 'self'
|
||||
});
|
||||
|
||||
|
||||
console.log('Cards from box response:', response);
|
||||
|
||||
// Convert to expected format
|
||||
const result = {
|
||||
@@ -494,7 +611,7 @@ class AtprotoOAuthService {
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
|
||||
console.error('カードボックス取得エラー:', error);
|
||||
|
||||
// If record doesn't exist, return empty
|
||||
if (error.toString().includes('RecordNotFound')) {
|
||||
@@ -516,8 +633,8 @@ class AtprotoOAuthService {
|
||||
const did = sessionInfo.did;
|
||||
|
||||
try {
|
||||
|
||||
|
||||
console.log('Deleting card box collection...');
|
||||
console.log('Using DID:', did);
|
||||
|
||||
// Ensure we have a fresh agent
|
||||
if (!this.agent) {
|
||||
@@ -530,35 +647,33 @@ class AtprotoOAuthService {
|
||||
rkey: 'self'
|
||||
});
|
||||
|
||||
|
||||
console.log('Card box deleted successfully:', response);
|
||||
} catch (error) {
|
||||
|
||||
console.error('カードボックス削除エラー:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 手動でトークンを設定(開発・デバッグ用)
|
||||
setManualTokens(accessJwt: string, refreshJwt: string): void {
|
||||
|
||||
|
||||
console.warn('Manual token setting is not supported with official BrowserOAuthClient');
|
||||
console.warn('Please use the proper OAuth flow instead');
|
||||
|
||||
// For backward compatibility, store in localStorage
|
||||
const adminDid = import.meta.env.VITE_ADMIN_DID || 'did:plc:unknown';
|
||||
const appHost = import.meta.env.VITE_APP_HOST || 'https://example.com';
|
||||
const session: AtprotoSession = {
|
||||
did: adminDid,
|
||||
handle: new URL(appHost).hostname,
|
||||
did: 'did:plc:uqzpqmrjnptsxezjx4xuh2mn',
|
||||
handle: 'syui.ai',
|
||||
accessJwt: accessJwt,
|
||||
refreshJwt: refreshJwt
|
||||
};
|
||||
|
||||
localStorage.setItem('atproto_session', JSON.stringify(session));
|
||||
|
||||
console.log('Manual tokens stored in localStorage for backward compatibility');
|
||||
}
|
||||
|
||||
// 後方互換性のための従来関数
|
||||
saveSessionToStorage(session: AtprotoSession): void {
|
||||
|
||||
console.warn('saveSessionToStorage is deprecated with BrowserOAuthClient');
|
||||
localStorage.setItem('atproto_session', JSON.stringify(session));
|
||||
}
|
||||
|
||||
|
@@ -1,135 +0,0 @@
|
||||
// Simple console test for OAuth app
|
||||
// This runs before 'npm run preview' to display test results
|
||||
|
||||
// Mock import.meta.env for Node.js environment
|
||||
(global as any).import = {
|
||||
meta: {
|
||||
env: {
|
||||
VITE_ATPROTO_PDS: process.env.VITE_ATPROTO_PDS || 'syu.is',
|
||||
VITE_ADMIN_HANDLE: process.env.VITE_ADMIN_HANDLE || 'ai.syui.ai',
|
||||
VITE_AI_HANDLE: process.env.VITE_AI_HANDLE || 'ai.syui.ai',
|
||||
VITE_OAUTH_COLLECTION: process.env.VITE_OAUTH_COLLECTION || 'ai.syui.log',
|
||||
VITE_ATPROTO_HANDLE_LIST: process.env.VITE_ATPROTO_HANDLE_LIST || '["syui.ai", "ai.syui.ai", "yui.syui.ai"]',
|
||||
VITE_APP_HOST: process.env.VITE_APP_HOST || 'https://log.syui.ai'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Simple implementation of functions for testing
|
||||
function detectPdsFromHandle(handle: string): string {
|
||||
if (handle.endsWith('.syu.is') || handle.endsWith('.syui.ai')) {
|
||||
return 'syu.is';
|
||||
}
|
||||
if (handle.endsWith('.bsky.social')) {
|
||||
return 'bsky.social';
|
||||
}
|
||||
// Default case - check if it's in the allowed list
|
||||
const allowedHandles = JSON.parse((global as any).import.meta.env.VITE_ATPROTO_HANDLE_LIST || '[]');
|
||||
if (allowedHandles.includes(handle)) {
|
||||
return (global as any).import.meta.env.VITE_ATPROTO_PDS || 'syu.is';
|
||||
}
|
||||
return 'bsky.social';
|
||||
}
|
||||
|
||||
function getNetworkConfig(pds: string) {
|
||||
switch (pds) {
|
||||
case 'bsky.social':
|
||||
case 'bsky.app':
|
||||
return {
|
||||
pdsApi: `https://${pds}`,
|
||||
plcApi: 'https://plc.directory',
|
||||
bskyApi: 'https://public.api.bsky.app',
|
||||
webUrl: 'https://bsky.app'
|
||||
};
|
||||
case 'syu.is':
|
||||
return {
|
||||
pdsApi: 'https://syu.is',
|
||||
plcApi: 'https://plc.syu.is',
|
||||
bskyApi: 'https://bsky.syu.is',
|
||||
webUrl: 'https://web.syu.is'
|
||||
};
|
||||
default:
|
||||
return {
|
||||
pdsApi: `https://${pds}`,
|
||||
plcApi: 'https://plc.directory',
|
||||
bskyApi: 'https://public.api.bsky.app',
|
||||
webUrl: 'https://bsky.app'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Main test execution
|
||||
console.log('\n=== OAuth App Configuration Tests ===\n');
|
||||
|
||||
// Test 1: Handle input behavior
|
||||
console.log('1. Handle Input → PDS Detection:');
|
||||
const testHandles = [
|
||||
'syui.ai',
|
||||
'syui.syu.is',
|
||||
'syui.syui.ai',
|
||||
'test.bsky.social',
|
||||
'unknown.handle'
|
||||
];
|
||||
|
||||
testHandles.forEach(handle => {
|
||||
const pds = detectPdsFromHandle(handle);
|
||||
const config = getNetworkConfig(pds);
|
||||
console.log(` ${handle.padEnd(20)} → PDS: ${pds.padEnd(12)} → API: ${config.pdsApi}`);
|
||||
});
|
||||
|
||||
// Test 2: Environment variable impact
|
||||
console.log('\n2. Current Environment Configuration:');
|
||||
const env = (global as any).import.meta.env;
|
||||
console.log(` VITE_ATPROTO_PDS: ${env.VITE_ATPROTO_PDS}`);
|
||||
console.log(` VITE_ADMIN_HANDLE: ${env.VITE_ADMIN_HANDLE}`);
|
||||
console.log(` VITE_AI_HANDLE: ${env.VITE_AI_HANDLE}`);
|
||||
console.log(` VITE_OAUTH_COLLECTION: ${env.VITE_OAUTH_COLLECTION}`);
|
||||
console.log(` VITE_ATPROTO_HANDLE_LIST: ${env.VITE_ATPROTO_HANDLE_LIST}`);
|
||||
|
||||
// Test 3: API endpoint generation
|
||||
console.log('\n3. Generated API Endpoints:');
|
||||
const adminPds = detectPdsFromHandle(env.VITE_ADMIN_HANDLE);
|
||||
const adminConfig = getNetworkConfig(adminPds);
|
||||
console.log(` Admin PDS detection: ${env.VITE_ADMIN_HANDLE} → ${adminPds}`);
|
||||
console.log(` Admin API endpoints:`);
|
||||
console.log(` - PDS API: ${adminConfig.pdsApi}`);
|
||||
console.log(` - Bsky API: ${adminConfig.bskyApi}`);
|
||||
console.log(` - Web URL: ${adminConfig.webUrl}`);
|
||||
|
||||
// Test 4: Collection URLs
|
||||
console.log('\n4. Collection API URLs:');
|
||||
const baseCollection = env.VITE_OAUTH_COLLECTION;
|
||||
console.log(` User list: ${adminConfig.pdsApi}/xrpc/com.atproto.repo.listRecords?repo=${env.VITE_ADMIN_HANDLE}&collection=${baseCollection}.user`);
|
||||
console.log(` Chat: ${adminConfig.pdsApi}/xrpc/com.atproto.repo.listRecords?repo=${env.VITE_ADMIN_HANDLE}&collection=${baseCollection}.chat`);
|
||||
console.log(` Lang: ${adminConfig.pdsApi}/xrpc/com.atproto.repo.listRecords?repo=${env.VITE_ADMIN_HANDLE}&collection=${baseCollection}.chat.lang`);
|
||||
console.log(` Comment: ${adminConfig.pdsApi}/xrpc/com.atproto.repo.listRecords?repo=${env.VITE_ADMIN_HANDLE}&collection=${baseCollection}.chat.comment`);
|
||||
|
||||
// Test 5: OAuth routing logic
|
||||
console.log('\n5. OAuth Authorization Logic:');
|
||||
const allowedHandles = JSON.parse(env.VITE_ATPROTO_HANDLE_LIST || '[]');
|
||||
console.log(` Allowed handles: ${JSON.stringify(allowedHandles)}`);
|
||||
console.log(` OAuth scenarios:`);
|
||||
|
||||
const oauthTestCases = [
|
||||
'syui.ai', // Should use syu.is (in allowed list)
|
||||
'test.syu.is', // Should use syu.is (*.syu.is pattern)
|
||||
'user.bsky.social' // Should use bsky.social (default)
|
||||
];
|
||||
|
||||
oauthTestCases.forEach(handle => {
|
||||
const pds = detectPdsFromHandle(handle);
|
||||
const isAllowed = allowedHandles.includes(handle);
|
||||
const reason = handle.endsWith('.syu.is') ? '*.syu.is pattern' :
|
||||
isAllowed ? 'in allowed list' :
|
||||
'default';
|
||||
console.log(` ${handle.padEnd(20)} → https://${pds}/oauth/authorize (${reason})`);
|
||||
});
|
||||
|
||||
// Test 6: AI Profile Resolution
|
||||
console.log('\n6. AI Profile Resolution:');
|
||||
const aiPds = detectPdsFromHandle(env.VITE_AI_HANDLE);
|
||||
const aiConfig = getNetworkConfig(aiPds);
|
||||
console.log(` AI Handle: ${env.VITE_AI_HANDLE} → PDS: ${aiPds}`);
|
||||
console.log(` AI Profile API: ${aiConfig.bskyApi}/xrpc/app.bsky.actor.getProfile?actor=${env.VITE_AI_HANDLE}`);
|
||||
|
||||
console.log('\n=== Tests Complete ===\n');
|
@@ -1,141 +0,0 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { getAppConfig } from '../config/app';
|
||||
import { detectPdsFromHandle, getNetworkConfig } from '../App';
|
||||
|
||||
// Test helper to mock environment variables
|
||||
const mockEnv = (vars: Record<string, string>) => {
|
||||
Object.keys(vars).forEach(key => {
|
||||
(import.meta.env as any)[key] = vars[key];
|
||||
});
|
||||
};
|
||||
|
||||
describe('OAuth App Tests', () => {
|
||||
describe('Handle Input Behavior', () => {
|
||||
it('should detect PDS for syui.ai (Bluesky)', () => {
|
||||
const pds = detectPdsFromHandle('syui.ai');
|
||||
expect(pds).toBe('bsky.social');
|
||||
});
|
||||
|
||||
it('should detect PDS for syui.syu.is (syu.is)', () => {
|
||||
const pds = detectPdsFromHandle('syui.syu.is');
|
||||
expect(pds).toBe('syu.is');
|
||||
});
|
||||
|
||||
it('should detect PDS for syui.syui.ai (syu.is)', () => {
|
||||
const pds = detectPdsFromHandle('syui.syui.ai');
|
||||
expect(pds).toBe('syu.is');
|
||||
});
|
||||
|
||||
it('should use network config for different PDS', () => {
|
||||
const bskyConfig = getNetworkConfig('bsky.social');
|
||||
expect(bskyConfig.pdsApi).toBe('https://bsky.social');
|
||||
expect(bskyConfig.bskyApi).toBe('https://public.api.bsky.app');
|
||||
expect(bskyConfig.webUrl).toBe('https://bsky.app');
|
||||
|
||||
const syuisConfig = getNetworkConfig('syu.is');
|
||||
expect(syuisConfig.pdsApi).toBe('https://syu.is');
|
||||
expect(syuisConfig.bskyApi).toBe('https://bsky.syu.is');
|
||||
expect(syuisConfig.webUrl).toBe('https://web.syu.is');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Environment Variable Changes', () => {
|
||||
beforeEach(() => {
|
||||
// Reset environment variables
|
||||
delete (import.meta.env as any).VITE_ATPROTO_PDS;
|
||||
delete (import.meta.env as any).VITE_ADMIN_HANDLE;
|
||||
delete (import.meta.env as any).VITE_AI_HANDLE;
|
||||
});
|
||||
|
||||
it('should use correct PDS for AI profile', () => {
|
||||
mockEnv({
|
||||
VITE_ATPROTO_PDS: 'syu.is',
|
||||
VITE_ADMIN_HANDLE: 'ai.syui.ai',
|
||||
VITE_AI_HANDLE: 'ai.syui.ai'
|
||||
});
|
||||
|
||||
const config = getAppConfig();
|
||||
expect(config.atprotoPds).toBe('syu.is');
|
||||
expect(config.adminHandle).toBe('ai.syui.ai');
|
||||
expect(config.aiHandle).toBe('ai.syui.ai');
|
||||
|
||||
// Network config should use syu.is endpoints
|
||||
const networkConfig = getNetworkConfig(config.atprotoPds);
|
||||
expect(networkConfig.bskyApi).toBe('https://bsky.syu.is');
|
||||
});
|
||||
|
||||
it('should construct correct API requests for admin userlist', () => {
|
||||
mockEnv({
|
||||
VITE_ATPROTO_PDS: 'syu.is',
|
||||
VITE_ADMIN_HANDLE: 'ai.syui.ai',
|
||||
VITE_OAUTH_COLLECTION: 'ai.syui.log'
|
||||
});
|
||||
|
||||
const config = getAppConfig();
|
||||
const networkConfig = getNetworkConfig(config.atprotoPds);
|
||||
const userListUrl = `${networkConfig.pdsApi}/xrpc/com.atproto.repo.listRecords?repo=${config.adminHandle}&collection=${config.collections.base}.user`;
|
||||
|
||||
expect(userListUrl).toBe('https://syu.is/xrpc/com.atproto.repo.listRecords?repo=ai.syui.ai&collection=ai.syui.log.user');
|
||||
});
|
||||
});
|
||||
|
||||
describe('OAuth Login Flow', () => {
|
||||
it('should use syu.is OAuth for handles in VITE_ATPROTO_HANDLE_LIST', () => {
|
||||
mockEnv({
|
||||
VITE_ATPROTO_HANDLE_LIST: '["syui.ai", "ai.syui.ai", "yui.syui.ai"]',
|
||||
VITE_ATPROTO_PDS: 'syu.is'
|
||||
});
|
||||
|
||||
const config = getAppConfig();
|
||||
const handle = 'syui.ai';
|
||||
|
||||
// Check if handle is in allowed list
|
||||
expect(config.allowedHandles).toContain(handle);
|
||||
|
||||
// Should use configured PDS for OAuth
|
||||
const expectedAuthUrl = `https://${config.atprotoPds}/oauth/authorize`;
|
||||
expect(expectedAuthUrl).toContain('syu.is');
|
||||
});
|
||||
|
||||
it('should use syu.is OAuth for *.syu.is handles', () => {
|
||||
const handle = 'test.syu.is';
|
||||
const pds = detectPdsFromHandle(handle);
|
||||
expect(pds).toBe('syu.is');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Terminal display test output
|
||||
export function runTerminalTests() {
|
||||
console.log('\n=== OAuth App Tests ===\n');
|
||||
|
||||
// Test 1: Handle input behavior
|
||||
console.log('1. Handle Input Detection:');
|
||||
const handles = ['syui.ai', 'syui.syu.is', 'syui.syui.ai'];
|
||||
handles.forEach(handle => {
|
||||
const pds = detectPdsFromHandle(handle);
|
||||
console.log(` ${handle} → PDS: ${pds}`);
|
||||
});
|
||||
|
||||
// Test 2: Environment variable impact
|
||||
console.log('\n2. Environment Variables:');
|
||||
const config = getAppConfig();
|
||||
console.log(` VITE_ATPROTO_PDS: ${config.atprotoPds}`);
|
||||
console.log(` VITE_ADMIN_HANDLE: ${config.adminHandle}`);
|
||||
console.log(` VITE_AI_HANDLE: ${config.aiHandle}`);
|
||||
console.log(` VITE_OAUTH_COLLECTION: ${config.collections.base}`);
|
||||
|
||||
// Test 3: API endpoints
|
||||
console.log('\n3. API Endpoints:');
|
||||
const networkConfig = getNetworkConfig(config.atprotoPds);
|
||||
console.log(` Admin PDS API: ${networkConfig.pdsApi}`);
|
||||
console.log(` Admin Bsky API: ${networkConfig.bskyApi}`);
|
||||
console.log(` User list URL: ${networkConfig.pdsApi}/xrpc/com.atproto.repo.listRecords?repo=${config.adminHandle}&collection=${config.collections.base}.user`);
|
||||
|
||||
// Test 4: OAuth routing
|
||||
console.log('\n4. OAuth Routing:');
|
||||
console.log(` Allowed handles: ${JSON.stringify(config.allowedHandles)}`);
|
||||
console.log(` OAuth endpoint: https://${config.atprotoPds}/oauth/authorize`);
|
||||
|
||||
console.log('\n=== End Tests ===\n');
|
||||
}
|
@@ -53,6 +53,7 @@ export class OAuthEndpointHandler {
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to generate JWKS:', error);
|
||||
return new Response(JSON.stringify({ error: 'Failed to generate JWKS' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
@@ -61,6 +62,7 @@ export class OAuthEndpointHandler {
|
||||
}
|
||||
} catch (e) {
|
||||
// If URL parsing fails, pass through to original fetch
|
||||
console.debug('URL parsing failed, passing through:', e);
|
||||
}
|
||||
|
||||
// Pass through all other requests
|
||||
@@ -134,5 +136,6 @@ export function registerOAuthServiceWorker() {
|
||||
const blob = new Blob([swCode], { type: 'application/javascript' });
|
||||
const swUrl = URL.createObjectURL(blob);
|
||||
|
||||
navigator.serviceWorker.register(swUrl).catch(console.error);
|
||||
}
|
||||
}
|
@@ -37,6 +37,7 @@ export class OAuthKeyManager {
|
||||
this.keyPair = await this.importKeyPair(keyData);
|
||||
return this.keyPair;
|
||||
} catch (error) {
|
||||
console.warn('Failed to load stored key, generating new one:', error);
|
||||
localStorage.removeItem('oauth_private_key');
|
||||
}
|
||||
}
|
||||
@@ -114,6 +115,7 @@ export class OAuthKeyManager {
|
||||
const privateKey = await window.crypto.subtle.exportKey('jwk', keyPair.privateKey);
|
||||
localStorage.setItem('oauth_private_key', JSON.stringify(privateKey));
|
||||
} catch (error) {
|
||||
console.error('Failed to store private key:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -1,348 +0,0 @@
|
||||
// PDS Detection and API URL mapping utilities
|
||||
|
||||
import { isValidDid, isValidHandle } from './validation';
|
||||
|
||||
export interface NetworkConfig {
|
||||
pdsApi: string;
|
||||
plcApi: string;
|
||||
bskyApi: string;
|
||||
webUrl: string;
|
||||
}
|
||||
|
||||
// Detect PDS from handle
|
||||
export function detectPdsFromHandle(handle: string): string {
|
||||
// Get allowed handles from environment
|
||||
const allowedHandlesStr = import.meta.env.VITE_ATPROTO_HANDLE_LIST || '[]';
|
||||
let allowedHandles: string[] = [];
|
||||
try {
|
||||
allowedHandles = JSON.parse(allowedHandlesStr);
|
||||
} catch {
|
||||
allowedHandles = [];
|
||||
}
|
||||
|
||||
// Get configured PDS from environment
|
||||
const configuredPds = import.meta.env.VITE_ATPROTO_PDS || 'syu.is';
|
||||
|
||||
// Check if handle is in allowed list
|
||||
if (allowedHandles.includes(handle)) {
|
||||
return configuredPds;
|
||||
}
|
||||
|
||||
// Check if handle ends with .syu.is or .syui.ai
|
||||
if (handle.endsWith('.syu.is') || handle.endsWith('.syui.ai')) {
|
||||
return 'syu.is';
|
||||
}
|
||||
|
||||
// Check if handle ends with .bsky.social or .bsky.app
|
||||
if (handle.endsWith('.bsky.social') || handle.endsWith('.bsky.app')) {
|
||||
return 'bsky.social';
|
||||
}
|
||||
|
||||
// Default to Bluesky for unknown domains
|
||||
return 'bsky.social';
|
||||
}
|
||||
|
||||
// Map PDS endpoint to network configuration
|
||||
export function getNetworkConfigFromPdsEndpoint(pdsEndpoint: string): NetworkConfig {
|
||||
try {
|
||||
const url = new URL(pdsEndpoint);
|
||||
const hostname = url.hostname;
|
||||
|
||||
// Map based on actual PDS endpoint
|
||||
if (hostname === 'syu.is') {
|
||||
return {
|
||||
pdsApi: 'https://syu.is', // PDS API (repo operations)
|
||||
plcApi: 'https://plc.syu.is', // PLC directory
|
||||
bskyApi: 'https://bsky.syu.is', // Bluesky API (getProfile, etc.)
|
||||
webUrl: 'https://web.syu.is' // Web interface
|
||||
};
|
||||
} else if (hostname.includes('bsky.network') || hostname === 'bsky.social' || hostname.includes('host.bsky.network')) {
|
||||
// All Bluesky infrastructure (including *.host.bsky.network)
|
||||
return {
|
||||
pdsApi: pdsEndpoint, // Use actual PDS endpoint (e.g., shiitake.us-east.host.bsky.network)
|
||||
plcApi: 'https://plc.directory', // Standard PLC directory
|
||||
bskyApi: 'https://public.api.bsky.app', // Bluesky public API (NOT PDS)
|
||||
webUrl: 'https://bsky.app' // Bluesky web interface
|
||||
};
|
||||
} else {
|
||||
// Unknown PDS, assume Bluesky-compatible but use PDS for repo operations
|
||||
return {
|
||||
pdsApi: pdsEndpoint, // Use actual PDS for repo ops
|
||||
plcApi: 'https://plc.directory', // Default PLC
|
||||
bskyApi: 'https://public.api.bsky.app', // Default to Bluesky API
|
||||
webUrl: 'https://bsky.app' // Default web interface
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
// Fallback for invalid URLs
|
||||
return {
|
||||
pdsApi: 'https://bsky.social',
|
||||
plcApi: 'https://plc.directory',
|
||||
bskyApi: 'https://public.api.bsky.app',
|
||||
webUrl: 'https://bsky.app'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy function for backwards compatibility
|
||||
export function getNetworkConfig(pds: string): NetworkConfig {
|
||||
// This now assumes pds is a hostname
|
||||
return getNetworkConfigFromPdsEndpoint(`https://${pds}`);
|
||||
}
|
||||
|
||||
// Get appropriate API URL for a user based on their handle
|
||||
export function getApiUrlForUser(handle: string): string {
|
||||
const pds = detectPdsFromHandle(handle);
|
||||
const config = getNetworkConfig(pds);
|
||||
return config.bskyApi;
|
||||
}
|
||||
|
||||
// Resolve handle/DID to actual PDS endpoint using PLC API first
|
||||
export async function resolvePdsFromRepo(handleOrDid: string): Promise<{ pds: string; did: string; handle: string }> {
|
||||
// Validate input
|
||||
if (!handleOrDid || (!isValidDid(handleOrDid) && !isValidHandle(handleOrDid))) {
|
||||
throw new Error(`Invalid identifier: ${handleOrDid}`);
|
||||
}
|
||||
|
||||
let targetDid = handleOrDid;
|
||||
let targetHandle = handleOrDid;
|
||||
|
||||
// If handle provided, resolve to DID first using identity.resolveHandle
|
||||
if (!handleOrDid.startsWith('did:')) {
|
||||
try {
|
||||
// Try multiple endpoints for handle resolution
|
||||
const resolveEndpoints = ['https://public.api.bsky.app', 'https://bsky.syu.is', 'https://syu.is'];
|
||||
let resolved = false;
|
||||
|
||||
for (const endpoint of resolveEndpoints) {
|
||||
try {
|
||||
const resolveResponse = await fetch(`${endpoint}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handleOrDid)}`);
|
||||
if (resolveResponse.ok) {
|
||||
const resolveData = await resolveResponse.json();
|
||||
targetDid = resolveData.did;
|
||||
resolved = true;
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!resolved) {
|
||||
throw new Error('Handle resolution failed from all endpoints');
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to resolve handle ${handleOrDid} to DID: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
// First, try PLC API to get the authoritative DID document
|
||||
const plcApis = ['https://plc.directory', 'https://plc.syu.is'];
|
||||
|
||||
for (const plcApi of plcApis) {
|
||||
try {
|
||||
const plcResponse = await fetch(`${plcApi}/${targetDid}`);
|
||||
if (plcResponse.ok) {
|
||||
const didDocument = await plcResponse.json();
|
||||
|
||||
// Find PDS service in DID document
|
||||
const pdsService = didDocument.service?.find((s: any) =>
|
||||
s.id === '#atproto_pds' || s.type === 'AtprotoPersonalDataServer'
|
||||
);
|
||||
|
||||
if (pdsService && pdsService.serviceEndpoint) {
|
||||
return {
|
||||
pds: pdsService.serviceEndpoint,
|
||||
did: targetDid,
|
||||
handle: targetHandle
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: use com.atproto.repo.describeRepo to get PDS from known PDS endpoints
|
||||
const pdsEndpoints = ['https://bsky.social', 'https://syu.is'];
|
||||
|
||||
for (const pdsEndpoint of pdsEndpoints) {
|
||||
try {
|
||||
const response = await fetch(`${pdsEndpoint}/xrpc/com.atproto.repo.describeRepo?repo=${encodeURIComponent(targetDid)}`);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
// Extract PDS from didDoc.service
|
||||
const services = data.didDoc?.service || [];
|
||||
const pdsService = services.find((s: any) =>
|
||||
s.id === '#atproto_pds' || s.type === 'AtprotoPersonalDataServer'
|
||||
);
|
||||
|
||||
if (pdsService) {
|
||||
return {
|
||||
pds: pdsService.serviceEndpoint,
|
||||
did: data.did || targetDid,
|
||||
handle: data.handle || targetHandle
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Failed to resolve PDS for ${handleOrDid} from any endpoint`);
|
||||
}
|
||||
|
||||
// Resolve DID to actual PDS endpoint using com.atproto.repo.describeRepo
|
||||
export async function resolvePdsFromDid(did: string): Promise<string> {
|
||||
const resolved = await resolvePdsFromRepo(did);
|
||||
return resolved.pds;
|
||||
}
|
||||
|
||||
// Enhanced resolve handle to DID with proper PDS detection
|
||||
export async function resolveHandleToDid(handle: string): Promise<{ did: string; pds: string }> {
|
||||
try {
|
||||
// First, try to resolve the handle to DID using multiple methods
|
||||
const apiUrl = getApiUrlForUser(handle);
|
||||
const response = await fetch(`${apiUrl}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to resolve handle: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const did = data.did;
|
||||
|
||||
// Now resolve the actual PDS from the DID
|
||||
const actualPds = await resolvePdsFromDid(did);
|
||||
|
||||
return {
|
||||
did: did,
|
||||
pds: actualPds
|
||||
};
|
||||
} catch (error) {
|
||||
// Failed to resolve handle
|
||||
|
||||
// Fallback to handle-based detection
|
||||
const fallbackPds = detectPdsFromHandle(handle);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Get profile using appropriate API for the user with accurate PDS resolution
|
||||
export async function getProfileForUser(handleOrDid: string, knownPdsEndpoint?: string): Promise<any> {
|
||||
try {
|
||||
let apiUrl: string;
|
||||
|
||||
if (knownPdsEndpoint) {
|
||||
// If we already know the user's PDS endpoint, use it directly
|
||||
const config = getNetworkConfigFromPdsEndpoint(knownPdsEndpoint);
|
||||
apiUrl = config.bskyApi;
|
||||
} else {
|
||||
// Resolve the user's actual PDS using describeRepo
|
||||
try {
|
||||
const resolved = await resolvePdsFromRepo(handleOrDid);
|
||||
const config = getNetworkConfigFromPdsEndpoint(resolved.pds);
|
||||
apiUrl = config.bskyApi;
|
||||
} catch {
|
||||
// Fallback to handle-based detection
|
||||
apiUrl = getApiUrlForUser(handleOrDid);
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiUrl}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handleOrDid)}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get profile: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
// Failed to get profile
|
||||
|
||||
// Final fallback: try with default Bluesky API
|
||||
try {
|
||||
const response = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handleOrDid)}`);
|
||||
if (response.ok) {
|
||||
return await response.json();
|
||||
}
|
||||
} catch {
|
||||
// Ignore fallback errors
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Test and verify PDS detection methods
|
||||
export async function verifyPdsDetection(handleOrDid: string): Promise<void> {
|
||||
try {
|
||||
// Method 1: com.atproto.repo.describeRepo (PRIMARY METHOD)
|
||||
try {
|
||||
const resolved = await resolvePdsFromRepo(handleOrDid);
|
||||
const config = getNetworkConfigFromPdsEndpoint(resolved.pds);
|
||||
} catch (error) {
|
||||
// describeRepo failed
|
||||
}
|
||||
|
||||
// Method 2: com.atproto.identity.resolveHandle (for comparison)
|
||||
if (!handleOrDid.startsWith('did:')) {
|
||||
try {
|
||||
const resolveResponse = await fetch(`https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handleOrDid)}`);
|
||||
if (resolveResponse.ok) {
|
||||
const resolveData = await resolveResponse.json();
|
||||
}
|
||||
} catch (error) {
|
||||
// Error resolving handle
|
||||
}
|
||||
}
|
||||
|
||||
// Method 3: PLC Directory lookup (if we have a DID)
|
||||
let targetDid = handleOrDid;
|
||||
if (!handleOrDid.startsWith('did:')) {
|
||||
try {
|
||||
const profile = await getProfileForUser(handleOrDid);
|
||||
targetDid = profile.did;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const plcResponse = await fetch(`https://plc.directory/${targetDid}`);
|
||||
if (plcResponse.ok) {
|
||||
const didDocument = await plcResponse.json();
|
||||
|
||||
// Find PDS service
|
||||
const pdsService = didDocument.service?.find((s: any) =>
|
||||
s.id === '#atproto_pds' || s.type === 'AtprotoPersonalDataServer'
|
||||
);
|
||||
|
||||
if (pdsService) {
|
||||
// Try to detect if this is a known network
|
||||
const pdsUrl = pdsService.serviceEndpoint;
|
||||
const hostname = new URL(pdsUrl).hostname;
|
||||
const detectedNetwork = detectPdsFromHandle(`user.${hostname}`);
|
||||
const networkConfig = getNetworkConfig(hostname);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Error fetching from PLC directory
|
||||
}
|
||||
|
||||
// Method 4: Our enhanced resolution
|
||||
try {
|
||||
if (handleOrDid.startsWith('did:')) {
|
||||
const pdsEndpoint = await resolvePdsFromDid(handleOrDid);
|
||||
} else {
|
||||
const resolved = await resolveHandleToDid(handleOrDid);
|
||||
}
|
||||
} catch (error) {
|
||||
// Enhanced resolution failed
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
// Overall verification failed
|
||||
}
|
||||
}
|
@@ -1,21 +0,0 @@
|
||||
// Validation utilities for atproto identifiers
|
||||
|
||||
export function isValidDid(did: string): boolean {
|
||||
if (!did || typeof did !== 'string') return false;
|
||||
|
||||
// Basic DID format: did:method:identifier
|
||||
const didRegex = /^did:[a-z]+:[a-zA-Z0-9._%-]+$/;
|
||||
return didRegex.test(did);
|
||||
}
|
||||
|
||||
export function isValidHandle(handle: string): boolean {
|
||||
if (!handle || typeof handle !== 'string') return false;
|
||||
|
||||
// Basic handle format: subdomain.domain.tld
|
||||
const handleRegex = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/;
|
||||
return handleRegex.test(handle);
|
||||
}
|
||||
|
||||
export function isValidAtprotoIdentifier(identifier: string): boolean {
|
||||
return isValidDid(identifier) || isValidHandle(identifier);
|
||||
}
|
@@ -1,6 +0,0 @@
|
||||
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
|
@@ -1,334 +0,0 @@
|
||||
# 開発ガイド
|
||||
|
||||
## 設計思想
|
||||
|
||||
このプロジェクトは以下の原則に基づいて設計されています:
|
||||
|
||||
### 1. 環境変数による設定の外部化
|
||||
- ハードコードを避け、設定は全て環境変数で管理
|
||||
- `src/config/env.js` で一元管理
|
||||
|
||||
### 2. PDS(Personal Data Server)の自動判定
|
||||
- `VITE_HANDLE_LIST` と `VITE_PDS` による自動判定
|
||||
- syu.is系とbsky.social系の自動振り分け
|
||||
|
||||
### 3. コンポーネントの責任分離
|
||||
- Hooks: ビジネスロジック
|
||||
- Components: UI表示のみ
|
||||
- Services: 外部API連携
|
||||
- Utils: 純粋関数
|
||||
|
||||
## アーキテクチャ詳細
|
||||
|
||||
### データフロー
|
||||
|
||||
```
|
||||
User Input
|
||||
↓
|
||||
Hooks (useAuth, useAdminData, usePageContext)
|
||||
↓
|
||||
Services (OAuthService)
|
||||
↓
|
||||
API (atproto.js)
|
||||
↓
|
||||
ATProto Network
|
||||
↓
|
||||
Components (UI Display)
|
||||
```
|
||||
|
||||
### 状態管理
|
||||
|
||||
React Hooksによる状態管理:
|
||||
- `useAuth`: OAuth認証状態
|
||||
- `useAdminData`: 管理者データ(プロフィール、レコード)
|
||||
- `usePageContext`: ページ判定(トップ/個別)
|
||||
|
||||
### OAuth認証フロー
|
||||
|
||||
```
|
||||
1. ユーザーがハンドル入力
|
||||
2. PDS判定 (syu.is vs bsky.social)
|
||||
3. 適切なOAuthClientを選択
|
||||
4. 標準OAuth画面にリダイレクト
|
||||
5. 認証完了後コールバック処理
|
||||
6. セッション復元・保存
|
||||
```
|
||||
|
||||
## 重要な実装詳細
|
||||
|
||||
### セッション管理
|
||||
|
||||
`@atproto/oauth-client-browser`が自動的に以下を処理:
|
||||
- IndexedDBへのセッション保存
|
||||
- トークンの自動更新
|
||||
- DPoP(Demonstration of Proof of Possession)
|
||||
|
||||
**注意**: 手動でのセッション管理は複雑なため、公式ライブラリを使用すること。
|
||||
|
||||
### PDS判定アルゴリズム
|
||||
|
||||
```javascript
|
||||
// src/utils/pds.js
|
||||
function isSyuIsHandle(handle) {
|
||||
return env.handleList.includes(handle) || handle.endsWith(`.${env.pds}`)
|
||||
}
|
||||
```
|
||||
|
||||
1. `VITE_HANDLE_LIST` に含まれるハンドル → syu.is
|
||||
2. `.syu.is` で終わるハンドル → syu.is
|
||||
3. その他 → bsky.social
|
||||
|
||||
### レコードフィルタリング
|
||||
|
||||
```javascript
|
||||
// src/components/RecordTabs.jsx
|
||||
const filterRecords = (records) => {
|
||||
if (pageContext.isTopPage) {
|
||||
return records.slice(0, 3) // 最新3件
|
||||
} else {
|
||||
// URL のrkey と record.value.post.url のrkey を照合
|
||||
return records.filter(record => {
|
||||
const recordRkey = new URL(record.value.post.url).pathname.split('/').pop()?.replace(/\.html$/, '')
|
||||
return recordRkey === pageContext.rkey
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 開発時の注意点
|
||||
|
||||
### 1. 環境変数の命名
|
||||
|
||||
- `VITE_` プレフィックス必須(Viteの制約)
|
||||
- JSON形式の環境変数は文字列として定義
|
||||
|
||||
```bash
|
||||
# ❌ 間違い
|
||||
VITE_HANDLE_LIST=["ai.syui.ai"]
|
||||
|
||||
# ✅ 正しい
|
||||
VITE_HANDLE_LIST=["ai.syui.ai", "syui.syui.ai"]
|
||||
```
|
||||
|
||||
### 2. API エラーハンドリング
|
||||
|
||||
```javascript
|
||||
// src/api/atproto.js
|
||||
async function request(url) {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
return await response.json()
|
||||
}
|
||||
```
|
||||
|
||||
すべてのAPI呼び出しでエラーハンドリングを実装。
|
||||
|
||||
### 3. コンポーネント設計
|
||||
|
||||
```javascript
|
||||
// ❌ Bad: ビジネスロジックがコンポーネント内
|
||||
function MyComponent() {
|
||||
const [data, setData] = useState([])
|
||||
useEffect(() => {
|
||||
fetch('/api/data').then(setData)
|
||||
}, [])
|
||||
return <div>{data.map(...)}</div>
|
||||
}
|
||||
|
||||
// ✅ Good: Hooksでロジック分離
|
||||
function MyComponent() {
|
||||
const { data, loading, error } = useMyData()
|
||||
if (loading) return <Loading />
|
||||
if (error) return <Error />
|
||||
return <div>{data.map(...)}</div>
|
||||
}
|
||||
```
|
||||
|
||||
## デバッグ手法
|
||||
|
||||
### 1. OAuth デバッグ
|
||||
|
||||
```javascript
|
||||
// ブラウザの開発者ツールで確認
|
||||
localStorage.clear() // セッションクリア
|
||||
sessionStorage.clear() // 一時データクリア
|
||||
|
||||
// IndexedDB確認(Application タブ)
|
||||
// ATProtoの認証データが保存される
|
||||
```
|
||||
|
||||
### 2. PDS判定デバッグ
|
||||
|
||||
```javascript
|
||||
// src/utils/pds.js にログ追加
|
||||
console.log('Handle:', handle)
|
||||
console.log('Is syu.is:', isSyuIsHandle(handle))
|
||||
console.log('API Config:', getApiConfig(pds))
|
||||
```
|
||||
|
||||
### 3. レコードフィルタリングデバッグ
|
||||
|
||||
```javascript
|
||||
// src/components/RecordTabs.jsx
|
||||
console.log('Page Context:', pageContext)
|
||||
console.log('All Records:', records.length)
|
||||
console.log('Filtered Records:', filteredRecords.length)
|
||||
```
|
||||
|
||||
## パフォーマンス最適化
|
||||
|
||||
### 1. 並列データ取得
|
||||
|
||||
```javascript
|
||||
// src/hooks/useAdminData.js
|
||||
const [records, lang, comment] = await Promise.all([
|
||||
collections.getBase(apiConfig.pds, did, env.collection),
|
||||
collections.getLang(apiConfig.pds, did, env.collection),
|
||||
collections.getComment(apiConfig.pds, did, env.collection)
|
||||
])
|
||||
```
|
||||
|
||||
### 2. 不要な再レンダリング防止
|
||||
|
||||
```javascript
|
||||
// useMemo でフィルタリング結果をキャッシュ
|
||||
const filteredRecords = useMemo(() =>
|
||||
filterRecords(records),
|
||||
[records, pageContext]
|
||||
)
|
||||
```
|
||||
|
||||
## テスト戦略
|
||||
|
||||
### 1. 単体テスト推奨対象
|
||||
|
||||
- `src/utils/pds.js` - PDS判定ロジック
|
||||
- `src/config/env.js` - 環境変数パース
|
||||
- フィルタリング関数
|
||||
|
||||
### 2. 統合テスト推奨対象
|
||||
|
||||
- OAuth認証フロー
|
||||
- API呼び出し
|
||||
- レコード表示
|
||||
|
||||
## デプロイメント
|
||||
|
||||
### 1. 必要ファイル
|
||||
|
||||
```
|
||||
public/
|
||||
└── client-metadata.json # OAuth設定ファイル
|
||||
|
||||
dist/ # ビルド出力
|
||||
├── index.html
|
||||
└── assets/
|
||||
├── comment-atproto-[hash].js
|
||||
└── comment-atproto-[hash].css
|
||||
```
|
||||
|
||||
### 2. デプロイ手順
|
||||
|
||||
```bash
|
||||
# 1. 環境変数設定
|
||||
cp .env.example .env
|
||||
# 2. 本番用設定を記入
|
||||
# 3. ビルド
|
||||
npm run build
|
||||
# 4. dist/ フォルダをデプロイ
|
||||
```
|
||||
|
||||
### 3. 本番環境チェックリスト
|
||||
|
||||
- [ ] `.env` ファイルの本番設定
|
||||
- [ ] `client-metadata.json` の設置
|
||||
- [ ] HTTPS 必須(OAuth要件)
|
||||
- [ ] CSP(Content Security Policy)設定
|
||||
|
||||
## よくある問題と解決法
|
||||
|
||||
### 1. "OAuth initialization failed"
|
||||
|
||||
**原因**: client-metadata.json が見つからない、または形式が正しくない
|
||||
|
||||
**解決法**:
|
||||
```bash
|
||||
# public/client-metadata.json の存在確認
|
||||
ls -la public/client-metadata.json
|
||||
|
||||
# 形式確認(JSON validation)
|
||||
jq . public/client-metadata.json
|
||||
```
|
||||
|
||||
### 2. "Failed to load admin data"
|
||||
|
||||
**原因**: 管理者アカウントのDID解決に失敗
|
||||
|
||||
**解決法**:
|
||||
```bash
|
||||
# 手動でDID解決確認
|
||||
curl "https://syu.is/xrpc/com.atproto.repo.describeRepo?repo=ai.syui.ai"
|
||||
```
|
||||
|
||||
### 3. レコードが表示されない
|
||||
|
||||
**原因**: コレクション名の不一致、権限不足
|
||||
|
||||
**解決法**:
|
||||
```bash
|
||||
# コレクション確認
|
||||
curl "https://syu.is/xrpc/com.atproto.repo.listRecords?repo=did:plc:xxx&collection=ai.syui.log.chat.lang"
|
||||
```
|
||||
|
||||
## 機能拡張ガイド
|
||||
|
||||
### 1. 新しいコレクション追加
|
||||
|
||||
```javascript
|
||||
// src/api/atproto.js に追加
|
||||
export const collections = {
|
||||
// 既存...
|
||||
async getNewCollection(pds, repo, collection, limit = 10) {
|
||||
return await atproto.getRecords(pds, repo, `${collection}.new`, limit)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 新しいPDS対応
|
||||
|
||||
```javascript
|
||||
// src/utils/pds.js を拡張
|
||||
export function getApiConfig(pds) {
|
||||
if (pds.includes('syu.is')) {
|
||||
// 既存の syu.is 設定
|
||||
} else if (pds.includes('newpds.com')) {
|
||||
return {
|
||||
pds: `https://newpds.com`,
|
||||
bsky: `https://bsky.newpds.com`,
|
||||
plc: `https://plc.newpds.com`,
|
||||
web: `https://web.newpds.com`
|
||||
}
|
||||
}
|
||||
// デフォルト設定
|
||||
}
|
||||
```
|
||||
|
||||
### 3. リアルタイム更新追加
|
||||
|
||||
```javascript
|
||||
// src/hooks/useRealtimeUpdates.js
|
||||
export function useRealtimeUpdates(collection) {
|
||||
useEffect(() => {
|
||||
const ws = new WebSocket('wss://jetstream2.us-east.bsky.network/subscribe')
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data)
|
||||
if (data.collection === collection) {
|
||||
// 新しいレコードを追加
|
||||
}
|
||||
}
|
||||
return () => ws.close()
|
||||
}, [collection])
|
||||
}
|
||||
```
|
@@ -1,222 +0,0 @@
|
||||
# ATProto OAuth Comment System
|
||||
|
||||
ATProtocol(Bluesky)のOAuth認証を使用したコメントシステムです。
|
||||
|
||||
## プロジェクト概要
|
||||
|
||||
このプロジェクトは、ATProtocolネットワーク上のコメントとlangレコードを表示するWebアプリケーションです。
|
||||
- 標準的なOAuth認証画面を使用
|
||||
- タブ切り替えでレコード表示
|
||||
- ページコンテキストに応じたフィルタリング
|
||||
|
||||
## ファイル構成
|
||||
|
||||
```
|
||||
src/
|
||||
├── config/
|
||||
│ └── env.js # 環境変数の一元管理
|
||||
├── utils/
|
||||
│ └── pds.js # PDS判定・API設定ユーティリティ
|
||||
├── api/
|
||||
│ └── atproto.js # ATProto API クライアント
|
||||
├── hooks/
|
||||
│ ├── useAuth.js # OAuth認証フック
|
||||
│ ├── useAdminData.js # 管理者データ取得フック
|
||||
│ └── usePageContext.js # ページ判定フック
|
||||
├── services/
|
||||
│ └── oauth.js # OAuth認証サービス
|
||||
├── components/
|
||||
│ ├── AuthButton.jsx # ログイン/ログアウトボタン
|
||||
│ ├── RecordTabs.jsx # Lang/Commentタブ切り替え
|
||||
│ ├── RecordList.jsx # レコード表示リスト
|
||||
│ ├── UserLookup.jsx # ユーザー検索(未使用)
|
||||
│ └── OAuthCallback.jsx # OAuth コールバック処理
|
||||
└── App.jsx # メインアプリケーション
|
||||
```
|
||||
|
||||
## 環境設定
|
||||
|
||||
### .env ファイル
|
||||
|
||||
```bash
|
||||
VITE_ADMIN=ai.syui.ai # 管理者ハンドル
|
||||
VITE_PDS=syu.is # デフォルトPDS
|
||||
VITE_HANDLE_LIST=["ai.syui.ai", "syui.syui.ai", "ai.ai"] # syu.is系ハンドルリスト
|
||||
VITE_COLLECTION=ai.syui.log # ベースコレクション
|
||||
VITE_OAUTH_CLIENT_ID=https://syui.ai/client-metadata.json # OAuth クライアントID
|
||||
VITE_OAUTH_REDIRECT_URI=https://syui.ai/oauth/callback # OAuth リダイレクトURI
|
||||
```
|
||||
|
||||
### 必要な依存関係
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.15.12",
|
||||
"@atproto/oauth-client-browser": "^0.3.19",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 主要機能
|
||||
|
||||
### 1. OAuth認証システム
|
||||
|
||||
**実装場所**: `src/services/oauth.js`
|
||||
|
||||
- `@atproto/oauth-client-browser`を使用した標準OAuth実装
|
||||
- bsky.social と syu.is 両方のPDSに対応
|
||||
- セッション自動復元機能
|
||||
|
||||
**重要**: ATProtoのセッション管理は複雑なため、公式ライブラリの使用が必須です。
|
||||
|
||||
### 2. PDS判定システム
|
||||
|
||||
**実装場所**: `src/utils/pds.js`
|
||||
|
||||
```javascript
|
||||
// ハンドル判定ロジック
|
||||
isSyuIsHandle(handle) → boolean
|
||||
// PDS設定取得
|
||||
getApiConfig(pds) → { pds, bsky, plc, web }
|
||||
```
|
||||
|
||||
環境変数`VITE_HANDLE_LIST`と`VITE_PDS`を基に自動判定します。
|
||||
|
||||
### 3. コレクション取得システム
|
||||
|
||||
**実装場所**: `src/api/atproto.js`
|
||||
|
||||
```javascript
|
||||
// 基本コレクション
|
||||
collections.getBase(pds, repo, collection)
|
||||
// lang コレクション(翻訳系)
|
||||
collections.getLang(pds, repo, collection) // → {collection}.chat.lang
|
||||
// comment コレクション(コメント系)
|
||||
collections.getComment(pds, repo, collection) // → {collection}.chat.comment
|
||||
```
|
||||
|
||||
### 4. ページコンテキスト判定
|
||||
|
||||
**実装場所**: `src/hooks/usePageContext.js`
|
||||
|
||||
```javascript
|
||||
// URL解析結果
|
||||
{
|
||||
isTopPage: boolean, // トップページかどうか
|
||||
rkey: string | null, // 個別ページのrkey(/posts/xxx → xxx)
|
||||
url: string // 現在のURL
|
||||
}
|
||||
```
|
||||
|
||||
## 表示ロジック
|
||||
|
||||
### フィルタリング
|
||||
|
||||
1. **トップページ**: 最新3件を表示
|
||||
2. **個別ページ**: `record.value.post.url`の rkey が現在ページと一致するもののみ表示
|
||||
|
||||
### タブ切り替え
|
||||
|
||||
- Lang Records: `{collection}.chat.lang`
|
||||
- Comment Records: `{collection}.chat.comment`
|
||||
|
||||
## 開発・デバッグ
|
||||
|
||||
### 起動コマンド
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # 開発サーバー
|
||||
npm run build # プロダクションビルド
|
||||
```
|
||||
|
||||
### OAuth デバッグ
|
||||
|
||||
1. **ローカル開発**: 自動的にloopback clientが使用される
|
||||
2. **本番環境**: `client-metadata.json`が必要
|
||||
|
||||
```json
|
||||
// public/client-metadata.json
|
||||
{
|
||||
"client_id": "https://syui.ai/client-metadata.json",
|
||||
"client_name": "ATProto Comment System",
|
||||
"redirect_uris": ["https://syui.ai/oauth/callback"],
|
||||
"scope": "atproto",
|
||||
"grant_types": ["authorization_code", "refresh_token"],
|
||||
"response_types": ["code"],
|
||||
"token_endpoint_auth_method": "none",
|
||||
"application_type": "web",
|
||||
"dpop_bound_access_tokens": true
|
||||
}
|
||||
```
|
||||
|
||||
### よくある問題
|
||||
|
||||
1. **セッションが保存されない**
|
||||
- `@atproto/oauth-client-browser`のバージョン確認
|
||||
- IndexedDBの確認(ブラウザの開発者ツール)
|
||||
|
||||
2. **PDS判定が正しく動作しない**
|
||||
- `VITE_HANDLE_LIST`の JSON 形式を確認
|
||||
- 環境変数の読み込み確認
|
||||
|
||||
3. **レコードが表示されない**
|
||||
- 管理者アカウントの DID 解決確認
|
||||
- コレクション名の確認(`{base}.chat.lang`, `{base}.chat.comment`)
|
||||
|
||||
## API エンドポイント
|
||||
|
||||
### 使用しているATProto API
|
||||
|
||||
1. **com.atproto.repo.describeRepo**
|
||||
- ハンドル → DID, PDS解決
|
||||
|
||||
2. **app.bsky.actor.getProfile**
|
||||
- プロフィール情報取得
|
||||
|
||||
3. **com.atproto.repo.listRecords**
|
||||
- コレクションレコード取得
|
||||
|
||||
## セキュリティ
|
||||
|
||||
- OAuth 2.1 + PKCE による認証
|
||||
- DPoP (Demonstration of Proof of Possession) 対応
|
||||
- セッション情報はブラウザのIndexedDBに暗号化保存
|
||||
|
||||
## 今後の拡張可能性
|
||||
|
||||
1. **コメント投稿機能**
|
||||
- 認証済みユーザーによるコメント作成
|
||||
- `com.atproto.repo.putRecord` API使用
|
||||
|
||||
2. **リアルタイム更新**
|
||||
- Jetstream WebSocket 接続
|
||||
- 新しいレコードの自動表示
|
||||
|
||||
3. **マルチPDS対応**
|
||||
- より多くのPDSへの対応
|
||||
- 動的PDS判定の改善
|
||||
|
||||
## トラブルシューティング
|
||||
|
||||
### ログ確認
|
||||
ブラウザの開発者ツールでコンソールログを確認してください。主要なエラーは以下の通りです:
|
||||
|
||||
- `OAuth initialization failed`: OAuth設定の問題
|
||||
- `Failed to load admin data`: API アクセスエラー
|
||||
- `Auth check failed`: セッション復元エラー
|
||||
|
||||
### 環境変数確認
|
||||
```javascript
|
||||
// 開発者ツールのコンソールで確認
|
||||
console.log(import.meta.env)
|
||||
```
|
||||
|
||||
## 参考資料
|
||||
|
||||
- [ATProto OAuth Guide](https://github.com/bluesky-social/atproto/blob/main/packages/api/OAUTH.md)
|
||||
- [BrowserOAuthClient Documentation](https://github.com/bluesky-social/atproto/tree/main/packages/oauth-client-browser)
|
||||
- [ATProto API Reference](https://docs.bsky.app/docs/advanced-guides/atproto-api)
|
@@ -1,11 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<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,22 +0,0 @@
|
||||
{
|
||||
"name": "oauth-simple",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"@atproto/api": "^0.15.12",
|
||||
"@atproto/oauth-client-browser": "^0.3.19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
@@ -1,76 +0,0 @@
|
||||
import React from 'react'
|
||||
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 OAuthCallback from './components/OAuthCallback.jsx'
|
||||
|
||||
export default function App() {
|
||||
const { user, agent, loading: authLoading, login, logout } = useAuth()
|
||||
const { adminData, langRecords, commentRecords, loading: dataLoading, error, refresh: refreshAdminData } = useAdminData()
|
||||
const { userComments, chatRecords, loading: userLoading, refresh: refreshUserData } = useUserData(adminData)
|
||||
const pageContext = usePageContext()
|
||||
|
||||
// Handle OAuth callback
|
||||
if (window.location.search.includes('code=')) {
|
||||
return <OAuthCallback />
|
||||
}
|
||||
|
||||
const isLoading = authLoading || dataLoading || userLoading
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||
<h1>ATProto OAuth Demo</h1>
|
||||
<p>読み込み中...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||
<h1>ATProto OAuth Demo</h1>
|
||||
<p style={{ color: 'red' }}>エラー: {error}</p>
|
||||
<button onClick={() => window.location.reload()}>
|
||||
再読み込み
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px', maxWidth: '800px', margin: '0 auto' }}>
|
||||
<header style={{ marginBottom: '20px' }}>
|
||||
<h1>ATProto OAuth Demo</h1>
|
||||
<AuthButton
|
||||
user={user}
|
||||
onLogin={login}
|
||||
onLogout={logout}
|
||||
loading={authLoading}
|
||||
/>
|
||||
</header>
|
||||
|
||||
<CommentForm
|
||||
user={user}
|
||||
agent={agent}
|
||||
onCommentPosted={() => {
|
||||
refreshAdminData?.()
|
||||
refreshUserData?.()
|
||||
}}
|
||||
/>
|
||||
|
||||
<RecordTabs
|
||||
langRecords={langRecords}
|
||||
commentRecords={commentRecords}
|
||||
userComments={userComments}
|
||||
chatRecords={chatRecords}
|
||||
apiConfig={adminData.apiConfig}
|
||||
pageContext={pageContext}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
@@ -1,80 +0,0 @@
|
||||
// ATProto API client
|
||||
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 = {}) {
|
||||
const response = await fetch(url, options)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
export const atproto = {
|
||||
async getDid(pds, handle) {
|
||||
const res = await request(`https://${pds}/xrpc/${ENDPOINTS.describeRepo}?repo=${handle}`)
|
||||
return res.did
|
||||
},
|
||||
|
||||
async getProfile(bsky, actor) {
|
||||
return await request(`${bsky}/xrpc/${ENDPOINTS.getProfile}?actor=${actor}`)
|
||||
},
|
||||
|
||||
async getRecords(pds, repo, collection, limit = 10) {
|
||||
const res = await request(`${pds}/xrpc/${ENDPOINTS.listRecords}?repo=${repo}&collection=${collection}&limit=${limit}`)
|
||||
return res.records || []
|
||||
},
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Collection specific methods
|
||||
export const collections = {
|
||||
async getBase(pds, repo, collection, limit = 10) {
|
||||
return await atproto.getRecords(pds, repo, collection, limit)
|
||||
},
|
||||
|
||||
async getLang(pds, repo, collection, limit = 10) {
|
||||
return await atproto.getRecords(pds, repo, `${collection}.chat.lang`, limit)
|
||||
},
|
||||
|
||||
async getComment(pds, repo, collection, limit = 10) {
|
||||
return await atproto.getRecords(pds, repo, `${collection}.chat.comment`, limit)
|
||||
},
|
||||
|
||||
async getChat(pds, repo, collection, limit = 10) {
|
||||
return await atproto.getRecords(pds, repo, `${collection}.chat`, limit)
|
||||
},
|
||||
|
||||
async getUserList(pds, repo, collection, limit = 100) {
|
||||
return await atproto.getRecords(pds, repo, `${collection}.user`, limit)
|
||||
},
|
||||
|
||||
async getUserComments(pds, repo, collection, limit = 10) {
|
||||
return await atproto.getRecords(pds, repo, collection, limit)
|
||||
}
|
||||
}
|
@@ -1,102 +0,0 @@
|
||||
import React, { useState } from 'react'
|
||||
|
||||
export default function AuthButton({ user, onLogin, onLogout, loading }) {
|
||||
const [handleInput, setHandleInput] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!handleInput.trim() || isLoading) return
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
await onLogin(handleInput.trim())
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error)
|
||||
alert('ログインに失敗しました: ' + error.message)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div>認証状態を確認中...</div>
|
||||
}
|
||||
|
||||
if (user) {
|
||||
return (
|
||||
<div className="auth-status">
|
||||
<div>ログイン中: <strong>{user.handle}</strong></div>
|
||||
<button onClick={onLogout} className="logout-btn">
|
||||
ログアウト
|
||||
</button>
|
||||
<style jsx>{`
|
||||
.auth-status {
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
background: #f9f9f9;
|
||||
}
|
||||
.logout-btn {
|
||||
margin-top: 5px;
|
||||
padding: 5px 10px;
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-form">
|
||||
<h3>OAuth認証</h3>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="text"
|
||||
value={handleInput}
|
||||
onChange={(e) => setHandleInput(e.target.value)}
|
||||
placeholder="Handle (e.g. your.handle.com)"
|
||||
disabled={isLoading}
|
||||
className="handle-input"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !handleInput.trim()}
|
||||
className="login-btn"
|
||||
>
|
||||
{isLoading ? 'ログイン中...' : 'ログイン'}
|
||||
</button>
|
||||
</form>
|
||||
<style jsx>{`
|
||||
.auth-form {
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.handle-input {
|
||||
width: 200px;
|
||||
margin-right: 10px;
|
||||
padding: 5px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.login-btn {
|
||||
padding: 5px 10px;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.login-btn:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
@@ -1,200 +0,0 @@
|
||||
import React, { useState } from 'react'
|
||||
import { atproto } from '../api/atproto.js'
|
||||
import { env } from '../config/env.js'
|
||||
|
||||
export default function CommentForm({ user, agent, onCommentPosted }) {
|
||||
const [text, setText] = useState('')
|
||||
const [url, setUrl] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!text.trim() || !url.trim()) return
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// Create ai.syui.log record structure
|
||||
const record = {
|
||||
repo: user.did,
|
||||
collection: env.collection,
|
||||
rkey: `comment-${Date.now()}`,
|
||||
record: {
|
||||
$type: env.collection,
|
||||
url: url.trim(),
|
||||
comments: [
|
||||
{
|
||||
url: url.trim(),
|
||||
text: text.trim(),
|
||||
author: {
|
||||
did: user.did,
|
||||
handle: user.handle,
|
||||
displayName: user.displayName,
|
||||
avatar: user.avatar
|
||||
},
|
||||
createdAt: new Date().toISOString()
|
||||
}
|
||||
],
|
||||
createdAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
// Post the record
|
||||
await atproto.putRecord(null, record, agent)
|
||||
|
||||
// Clear form
|
||||
setText('')
|
||||
setUrl('')
|
||||
|
||||
// Notify parent component
|
||||
if (onCommentPosted) {
|
||||
onCommentPosted()
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="comment-form-placeholder">
|
||||
<p>ログインしてコメントを投稿</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="comment-form">
|
||||
<h3>コメントを投稿</h3>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label htmlFor="comment-url">ページURL:</label>
|
||||
<input
|
||||
id="comment-url"
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://syui.ai/posts/example"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="comment-text">コメント:</label>
|
||||
<textarea
|
||||
id="comment-text"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="コメントを入力してください..."
|
||||
rows={4}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="error-message">
|
||||
エラー: {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-actions">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !text.trim() || !url.trim()}
|
||||
className="submit-btn"
|
||||
>
|
||||
{loading ? '投稿中...' : 'コメントを投稿'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<style jsx>{`
|
||||
.comment-form {
|
||||
border: 2px solid #007bff;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
.comment-form-placeholder {
|
||||
border: 2px dashed #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin: 20px 0;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
background: #f8f9fa;
|
||||
}
|
||||
.comment-form h3 {
|
||||
margin-top: 0;
|
||||
color: #007bff;
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
.form-group input,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #007bff;
|
||||
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
|
||||
}
|
||||
.form-group input:disabled,
|
||||
.form-group textarea:disabled {
|
||||
background: #e9ecef;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.error-message {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 15px;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
.form-actions {
|
||||
margin-top: 20px;
|
||||
}
|
||||
.submit-btn {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.submit-btn:hover:not(:disabled) {
|
||||
background: #0056b3;
|
||||
}
|
||||
.submit-btn:disabled {
|
||||
background: #6c757d;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
@@ -1,50 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
|
||||
export default function OAuthCallback({ onAuthSuccess }) {
|
||||
const [status, setStatus] = useState('OAuth認証処理中...')
|
||||
|
||||
useEffect(() => {
|
||||
handleCallback()
|
||||
}, [])
|
||||
|
||||
const handleCallback = async () => {
|
||||
try {
|
||||
// BrowserOAuthClientが自動的にコールバックを処理します
|
||||
// URLのパラメータを確認して成功を通知
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const code = urlParams.get('code')
|
||||
const error = urlParams.get('error')
|
||||
|
||||
if (error) {
|
||||
throw new Error(`OAuth error: ${error}`)
|
||||
}
|
||||
|
||||
if (code) {
|
||||
setStatus('認証成功!メインページに戻ります...')
|
||||
|
||||
// 少し待ってからメインページにリダイレクト
|
||||
setTimeout(() => {
|
||||
window.location.href = '/'
|
||||
}, 1500)
|
||||
} else {
|
||||
setStatus('認証情報が見つかりません')
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Callback error:', error)
|
||||
setStatus('認証エラー: ' + error.message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||
<h2>OAuth認証</h2>
|
||||
<p>{status}</p>
|
||||
{status.includes('エラー') && (
|
||||
<button onClick={() => window.location.href = '/'}>
|
||||
メインページに戻る
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
@@ -1,58 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
export default function RecordList({ title, records, apiConfig, showTitle = true }) {
|
||||
if (!records || records.length === 0) {
|
||||
return (
|
||||
<section>
|
||||
{showTitle && <h3>{title} (0)</h3>}
|
||||
<p>レコードがありません</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
{showTitle && <h3>{title} ({records.length})</h3>}
|
||||
{records.map((record, i) => (
|
||||
<div key={i} style={{ border: '1px solid #ddd', margin: '10px 0', padding: '10px' }}>
|
||||
{record.value.author?.avatar && (
|
||||
<img
|
||||
src={record.value.author.avatar}
|
||||
alt="avatar"
|
||||
style={{ width: '32px', height: '32px', borderRadius: '50%', marginRight: '10px' }}
|
||||
/>
|
||||
)}
|
||||
<div><strong>{record.value.author?.displayName || record.value.author?.handle}</strong></div>
|
||||
<div>
|
||||
Handle:
|
||||
<a
|
||||
href={`${apiConfig?.web}/profile/${record.value.author?.did}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ marginLeft: '5px' }}
|
||||
>
|
||||
{record.value.author?.handle}
|
||||
</a>
|
||||
</div>
|
||||
<div style={{ margin: '10px 0' }}>{record.value.text || record.value.content}</div>
|
||||
{record.value.post?.url && (
|
||||
<div>
|
||||
URL:
|
||||
<a
|
||||
href={record.value.post.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ marginLeft: '5px' }}
|
||||
>
|
||||
{record.value.post.url}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: '12px', color: '#666', marginTop: '10px' }}>
|
||||
{new Date(record.value.createdAt).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
}
|
@@ -1,151 +0,0 @@
|
||||
import React, { useState } from 'react'
|
||||
import RecordList from './RecordList.jsx'
|
||||
|
||||
export default function RecordTabs({ langRecords, commentRecords, userComments, chatRecords, apiConfig, pageContext }) {
|
||||
const [activeTab, setActiveTab] = useState('lang')
|
||||
|
||||
// Filter records based on page context
|
||||
const filterRecords = (records) => {
|
||||
if (pageContext.isTopPage) {
|
||||
// Top page: show latest 3 records
|
||||
return records.slice(0, 3)
|
||||
} else {
|
||||
// Individual page: show records matching the URL
|
||||
return records.filter(record => {
|
||||
const recordUrl = record.value.post?.url
|
||||
if (!recordUrl) return false
|
||||
|
||||
try {
|
||||
const recordRkey = new URL(recordUrl).pathname.split('/').pop()?.replace(/\.html$/, '')
|
||||
return recordRkey === pageContext.rkey
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const filteredLangRecords = filterRecords(langRecords)
|
||||
const filteredCommentRecords = filterRecords(commentRecords)
|
||||
const filteredUserComments = filterRecords(userComments || [])
|
||||
const filteredChatRecords = filterRecords(chatRecords || [])
|
||||
|
||||
return (
|
||||
<div className="record-tabs">
|
||||
<div className="tab-header">
|
||||
<button
|
||||
className={`tab-btn ${activeTab === 'lang' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('lang')}
|
||||
>
|
||||
Lang Records ({filteredLangRecords.length})
|
||||
</button>
|
||||
<button
|
||||
className={`tab-btn ${activeTab === 'comment' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('comment')}
|
||||
>
|
||||
Comment Records ({filteredCommentRecords.length})
|
||||
</button>
|
||||
<button
|
||||
className={`tab-btn ${activeTab === 'collection' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('collection')}
|
||||
>
|
||||
Collection ({filteredChatRecords.length})
|
||||
</button>
|
||||
<button
|
||||
className={`tab-btn ${activeTab === 'users' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('users')}
|
||||
>
|
||||
User Comments ({filteredUserComments.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="tab-content">
|
||||
{activeTab === 'lang' && (
|
||||
<RecordList
|
||||
title={pageContext.isTopPage ? "Latest Lang Records" : "Lang Records for this page"}
|
||||
records={filteredLangRecords}
|
||||
apiConfig={apiConfig}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'comment' && (
|
||||
<RecordList
|
||||
title={pageContext.isTopPage ? "Latest Comment Records" : "Comment Records for this page"}
|
||||
records={filteredCommentRecords}
|
||||
apiConfig={apiConfig}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'collection' && (
|
||||
<RecordList
|
||||
title={pageContext.isTopPage ? "Latest Collection Records" : "Collection Records for this page"}
|
||||
records={filteredChatRecords}
|
||||
apiConfig={apiConfig}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'users' && (
|
||||
<RecordList
|
||||
title={pageContext.isTopPage ? "Latest User Comments" : "User Comments for this page"}
|
||||
records={filteredUserComments}
|
||||
apiConfig={apiConfig}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="page-info">
|
||||
<small>
|
||||
{pageContext.isTopPage
|
||||
? "トップページ: 最新3件を表示"
|
||||
: `個別ページ: ${pageContext.rkey} に関連するレコードを表示`
|
||||
}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<style jsx>{`
|
||||
.record-tabs {
|
||||
margin: 20px 0;
|
||||
}
|
||||
.tab-header {
|
||||
display: flex;
|
||||
border-bottom: 2px solid #ddd;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.tab-btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
background: #f8f9fa;
|
||||
border-top: 2px solid transparent;
|
||||
border-left: 1px solid #ddd;
|
||||
border-right: 1px solid #ddd;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.tab-btn:first-child {
|
||||
border-left: none;
|
||||
}
|
||||
.tab-btn:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
.tab-btn.active {
|
||||
background: white;
|
||||
border-top-color: #007bff;
|
||||
border-bottom: 2px solid white;
|
||||
margin-bottom: -2px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.tab-btn:hover:not(.active) {
|
||||
background: #e9ecef;
|
||||
}
|
||||
.tab-content {
|
||||
min-height: 200px;
|
||||
}
|
||||
.page-info {
|
||||
margin-top: 10px;
|
||||
padding: 5px 10px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 3px;
|
||||
color: #666;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
@@ -1,115 +0,0 @@
|
||||
import React, { useState } from 'react'
|
||||
import { atproto } from '../api/atproto.js'
|
||||
import { getPdsFromHandle, getApiConfig } from '../utils/pds.js'
|
||||
|
||||
export default function UserLookup() {
|
||||
const [handleInput, setHandleInput] = useState('')
|
||||
const [userInfo, setUserInfo] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!handleInput.trim() || loading) return
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const userPds = await getPdsFromHandle(handleInput)
|
||||
const apiConfig = getApiConfig(userPds)
|
||||
const did = await atproto.getDid(userPds.replace('https://', ''), handleInput)
|
||||
const profile = await atproto.getProfile(apiConfig.bsky, did)
|
||||
|
||||
setUserInfo({
|
||||
handle: handleInput,
|
||||
pds: userPds,
|
||||
did,
|
||||
profile,
|
||||
config: apiConfig
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('User lookup failed:', error)
|
||||
setUserInfo({ error: error.message })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="user-lookup">
|
||||
<h3>ユーザー検索</h3>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<input
|
||||
type="text"
|
||||
value={handleInput}
|
||||
onChange={(e) => setHandleInput(e.target.value)}
|
||||
placeholder="Enter handle (e.g. syui.syui.ai)"
|
||||
disabled={loading}
|
||||
className="search-input"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !handleInput.trim()}
|
||||
className="search-btn"
|
||||
>
|
||||
{loading ? '検索中...' : '検索'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{userInfo && (
|
||||
<div className="user-result">
|
||||
<h4>ユーザー情報:</h4>
|
||||
{userInfo.error ? (
|
||||
<div className="error">エラー: {userInfo.error}</div>
|
||||
) : (
|
||||
<div className="user-details">
|
||||
<div>Handle: {userInfo.handle}</div>
|
||||
<div>PDS: {userInfo.pds}</div>
|
||||
<div>DID: {userInfo.did}</div>
|
||||
<div>Display Name: {userInfo.profile?.displayName}</div>
|
||||
<div>PDS API: {userInfo.config?.pds}</div>
|
||||
<div>Bsky API: {userInfo.config?.bsky}</div>
|
||||
<div>Web: {userInfo.config?.web}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style jsx>{`
|
||||
.user-lookup {
|
||||
margin: 20px 0;
|
||||
}
|
||||
.search-input {
|
||||
width: 200px;
|
||||
margin-right: 10px;
|
||||
padding: 5px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.search-btn {
|
||||
padding: 5px 10px;
|
||||
background: #28a745;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.search-btn:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.user-result {
|
||||
margin-top: 15px;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
background: #f9f9f9;
|
||||
}
|
||||
.error {
|
||||
color: #dc3545;
|
||||
}
|
||||
.user-details div {
|
||||
margin: 5px 0;
|
||||
}
|
||||
`}</style>
|
||||
</section>
|
||||
)
|
||||
}
|
@@ -1,17 +0,0 @@
|
||||
// Environment configuration
|
||||
export const env = {
|
||||
admin: import.meta.env.VITE_ADMIN,
|
||||
pds: import.meta.env.VITE_PDS,
|
||||
collection: import.meta.env.VITE_COLLECTION,
|
||||
handleList: (() => {
|
||||
try {
|
||||
return JSON.parse(import.meta.env.VITE_HANDLE_LIST || '[]')
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})(),
|
||||
oauth: {
|
||||
clientId: import.meta.env.VITE_OAUTH_CLIENT_ID,
|
||||
redirectUri: import.meta.env.VITE_OAUTH_REDIRECT_URI
|
||||
}
|
||||
}
|
@@ -1,57 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { atproto, collections } from '../api/atproto.js'
|
||||
import { getApiConfig } from '../utils/pds.js'
|
||||
import { env } from '../config/env.js'
|
||||
|
||||
export function useAdminData() {
|
||||
const [adminData, setAdminData] = useState({
|
||||
did: '',
|
||||
profile: null,
|
||||
records: [],
|
||||
apiConfig: null
|
||||
})
|
||||
const [langRecords, setLangRecords] = useState([])
|
||||
const [commentRecords, setCommentRecords] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadAdminData()
|
||||
}, [])
|
||||
|
||||
const loadAdminData = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const apiConfig = getApiConfig(`https://${env.pds}`)
|
||||
const did = await atproto.getDid(env.pds, env.admin)
|
||||
const profile = await atproto.getProfile(apiConfig.bsky, did)
|
||||
|
||||
// Load all data in parallel
|
||||
const [records, lang, comment] = await Promise.all([
|
||||
collections.getBase(apiConfig.pds, did, env.collection),
|
||||
collections.getLang(apiConfig.pds, did, env.collection),
|
||||
collections.getComment(apiConfig.pds, did, env.collection)
|
||||
])
|
||||
|
||||
setAdminData({ did, profile, records, apiConfig })
|
||||
setLangRecords(lang)
|
||||
setCommentRecords(comment)
|
||||
} catch (err) {
|
||||
console.error('Failed to load admin data:', err)
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
adminData,
|
||||
langRecords,
|
||||
commentRecords,
|
||||
loading,
|
||||
error,
|
||||
refresh: loadAdminData
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user