43 Commits

Author SHA1 Message Date
fcab7c7f83 fix oauth 2025-06-17 13:19:31 +09:00
51e4a492bc fix: display correct author for user chat messages
Previously, all chat messages in the 'chat' tab were showing with AI account info
regardless of the actual author. This fix ensures the author information from
the record is used, only falling back to AI profile when no author is present.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-17 13:11:04 +09:00
9ed35a6a1e fix oauth 2025-06-17 13:05:01 +09:00
74e1014e77 fix oauth plc 2025-06-17 12:48:55 +09:00
820e47f634 update binary 2025-06-17 11:01:42 +09:00
4dac4a83e0 fix atproto web link 2025-06-17 11:00:09 +09:00
fccf75949c v0.2.1: Fix async trait implementation warnings
🤖 Generated with [Claude Code](https://claude.ai/code)

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

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

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

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

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

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

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

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

🔐 Generated with Claude Code

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-14 19:52:08 +09:00
78 changed files with 5184 additions and 2671 deletions

View File

@@ -45,7 +45,14 @@
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git push:*)",
"Bash(git tag:*)"
"Bash(git tag:*)",
"Bash(../bin/ailog:*)",
"Bash(../target/release/ailog oauth build:*)",
"Bash(ailog:*)",
"WebFetch(domain:plc.directory)",
"WebFetch(domain:atproto.com)",
"WebFetch(domain:syu.is)",
"Bash(sed:*)"
],
"deny": []
}

View File

@@ -1,51 +0,0 @@
name: Build Binary
on:
workflow_dispatch: # Manual trigger
push:
branches: [ main ]
paths:
- 'src/**'
- 'Cargo.toml'
- 'Cargo.lock'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Rust
uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
- name: Cache cargo registry
uses: actions/cache@v4
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
- name: Cache cargo index
uses: actions/cache@v4
with:
path: ~/.cargo/git
key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}
- name: Cache target directory
uses: actions/cache@v4
with:
path: target
key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }}
- name: Build binary
run: cargo build --release
- name: Upload binary
uses: actions/upload-artifact@v4
with:
name: ailog-linux
path: target/release/ailog
retention-days: 30

View File

@@ -34,22 +34,67 @@ jobs:
- name: Copy OAuth build to static
run: |
mkdir -p my-blog/static/assets
cp -r oauth/dist/assets/* my-blog/static/assets/
cp oauth/dist/index.html my-blog/static/oauth/index.html || true
# 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
- name: Setup Rust
uses: actions-rs/toolchain@v1
- name: Cache ailog binary
uses: actions/cache@v4
with:
toolchain: stable
- name: Build ailog
run: cargo build --release
path: ./bin
key: ailog-bin-${{ runner.os }}
restore-keys: |
ailog-bin-${{ runner.os }}
- name: Setup ailog binary
run: |
# Get expected version from Cargo.toml
EXPECTED_VERSION=$(grep '^version' Cargo.toml | cut -d'"' -f2)
echo "Expected version from Cargo.toml: $EXPECTED_VERSION"
# Check current binary version if exists
if [ -f "./bin/ailog" ]; then
CURRENT_VERSION=$(./bin/ailog --version 2>/dev/null || echo "unknown")
echo "Current binary version: $CURRENT_VERSION"
else
CURRENT_VERSION="none"
echo "No binary found"
fi
# Check OS
OS="${{ runner.os }}"
echo "Runner OS: $OS"
# Use pre-packaged binary if version matches or extract from tar.gz
if [ "$CURRENT_VERSION" = "$EXPECTED_VERSION" ]; then
echo "Binary is up to date"
chmod +x ./bin/ailog
elif [ "$OS" = "Linux" ] && [ -f "./bin/ailog-linux-x86_64.tar.gz" ]; then
echo "Extracting ailog from pre-packaged tar.gz..."
cd bin
tar -xzf ailog-linux-x86_64.tar.gz
chmod +x ailog
cd ..
# Verify extracted version
EXTRACTED_VERSION=$(./bin/ailog --version 2>/dev/null || echo "unknown")
echo "Extracted binary version: $EXTRACTED_VERSION"
if [ "$EXTRACTED_VERSION" != "$EXPECTED_VERSION" ]; then
echo "Warning: Binary version mismatch. Expected $EXPECTED_VERSION but got $EXTRACTED_VERSION"
fi
else
echo "Error: No suitable binary found for OS: $OS"
exit 1
fi
- name: Build site with ailog
run: |
cd my-blog
../target/release/ailog build
../bin/ailog build
- name: List public directory
run: |

View File

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

View File

@@ -1,77 +0,0 @@
name: github pages (fast)
on:
push:
branches:
- main
paths-ignore:
- 'src/**'
- 'Cargo.toml'
- 'Cargo.lock'
jobs:
build-deploy:
runs-on: ubuntu-latest
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: Check and update ailog binary
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Get latest release version
LATEST_VERSION=$(curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \
https://api.github.com/repos/${{ github.repository }}/releases/latest | jq -r .tag_name)
echo "Latest version: $LATEST_VERSION"
# Check current binary version if exists
mkdir -p ./bin
if [ -f "./bin/ailog" ]; then
CURRENT_VERSION=$(./bin/ailog --version 2>/dev/null || echo "unknown")
echo "Current version: $CURRENT_VERSION"
else
CURRENT_VERSION="none"
echo "No binary found"
fi
# Download if version is different or binary doesn't exist
if [ "$CURRENT_VERSION" != "${LATEST_VERSION#v}" ]; then
echo "Downloading ailog $LATEST_VERSION..."
curl -sL -H "Authorization: Bearer $GITHUB_TOKEN" \
https://github.com/${{ github.repository }}/releases/download/$LATEST_VERSION/ailog-linux-x86_64.tar.gz | tar -xzf -
mv ailog ./bin/ailog
chmod +x ./bin/ailog
echo "Updated to version: $(./bin/ailog --version 2>/dev/null)"
else
echo "Binary is up to date"
chmod +x ./bin/ailog
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
./bin/ailog build --output ./public
touch ./public/.nojekyll
- name: Deploy
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./public
publish_branch: gh-pages

9
.gitignore vendored
View File

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

View File

@@ -1,6 +1,6 @@
[package]
name = "ailog"
version = "0.1.3"
version = "0.2.1"
edition = "2021"
authors = ["syui"]
description = "A static blog generator with AI features"
@@ -10,6 +10,10 @@ 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"
@@ -49,6 +53,7 @@ regex = "1.0"
tokio-tungstenite = { version = "0.21", features = ["rustls-tls-webpki-roots", "connect"], default-features = false }
futures-util = "0.3"
tungstenite = { version = "0.21", features = ["rustls-tls-webpki-roots"], default-features = false }
rpassword = "7.3"
[dev-dependencies]
tempfile = "3.14"

View File

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

1130
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -1,148 +0,0 @@
name: 'ailog Static Site Generator'
description: 'AI-powered static blog generator with atproto integration'
author: 'syui'
branding:
icon: 'book-open'
color: 'orange'
inputs:
content-dir:
description: 'Content directory containing markdown files'
required: false
default: 'content'
output-dir:
description: 'Output directory for generated site'
required: false
default: 'public'
template-dir:
description: 'Template directory'
required: false
default: 'templates'
static-dir:
description: 'Static assets directory'
required: false
default: 'static'
config-file:
description: 'Configuration file path'
required: false
default: 'ailog.toml'
ai-integration:
description: 'Enable AI features'
required: false
default: 'true'
atproto-integration:
description: 'Enable atproto/OAuth features'
required: false
default: 'true'
outputs:
site-url:
description: 'Generated site URL'
value: ${{ steps.generate.outputs.site-url }}
build-time:
description: 'Build time in seconds'
value: ${{ steps.generate.outputs.build-time }}
runs:
using: 'composite'
steps:
- name: Cache ailog binary
uses: actions/cache@v4
with:
path: ./bin
key: ailog-bin-${{ runner.os }}
restore-keys: |
ailog-bin-${{ runner.os }}
- name: Check and update ailog binary
shell: bash
run: |
# Get latest release version (for Gitea, adjust API endpoint if needed)
if command -v curl >/dev/null 2>&1; then
LATEST_VERSION=$(curl -s https://api.github.com/repos/syui/ailog/releases/latest | jq -r .tag_name 2>/dev/null || echo "v0.1.1")
else
LATEST_VERSION="v0.1.1" # fallback version
fi
echo "Target version: $LATEST_VERSION"
# Check current binary version if exists
mkdir -p ./bin
if [ -f "./bin/ailog" ]; then
CURRENT_VERSION=$(./bin/ailog --version 2>/dev/null || echo "unknown")
echo "Current version: $CURRENT_VERSION"
else
CURRENT_VERSION="none"
echo "No binary found"
fi
# Download if version is different or binary doesn't exist
if [ "$CURRENT_VERSION" != "${LATEST_VERSION#v}" ]; then
echo "Downloading ailog $LATEST_VERSION..."
# Try GitHub first, then fallback to local build
if curl -sL https://github.com/syui/ailog/releases/download/$LATEST_VERSION/ailog-linux-x86_64.tar.gz | tar -xzf - 2>/dev/null; then
mv ailog ./bin/ailog
chmod +x ./bin/ailog
echo "Downloaded binary: $(./bin/ailog --version 2>/dev/null)"
else
echo "Download failed, building from source..."
if command -v cargo >/dev/null 2>&1; then
cargo build --release
cp ./target/release/ailog ./bin/ailog
echo "Built from source: $(./bin/ailog --version 2>/dev/null)"
else
echo "Error: Neither download nor cargo build available"
exit 1
fi
fi
else
echo "Binary is up to date"
chmod +x ./bin/ailog
fi
- name: Setup Node.js for OAuth app
if: ${{ inputs.atproto-integration == 'true' }}
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Build OAuth app
if: ${{ inputs.atproto-integration == 'true' }}
shell: bash
run: |
if [ -d "oauth" ]; then
cd oauth
npm install
npm run build
cp -r dist/* ../${{ inputs.static-dir }}/
fi
- name: Generate site
id: generate
shell: bash
run: |
start_time=$(date +%s)
./bin/ailog build \
--content ${{ inputs.content-dir }} \
--output ${{ inputs.output-dir }} \
--templates ${{ inputs.template-dir }} \
--static ${{ inputs.static-dir }} \
--config ${{ inputs.config-file }}
end_time=$(date +%s)
build_time=$((end_time - start_time))
echo "build-time=${build_time}" >> $GITHUB_OUTPUT
echo "site-url=file://$(pwd)/${{ inputs.output-dir }}" >> $GITHUB_OUTPUT
- name: Display build summary
shell: bash
run: |
echo "✅ ailog build completed successfully"
echo "📁 Output directory: ${{ inputs.output-dir }}"
echo "⏱️ Build time: ${{ steps.generate.outputs.build-time }}s"
if [ -d "${{ inputs.output-dir }}" ]; then
echo "📄 Generated files:"
find ${{ inputs.output-dir }} -type f | head -10
fi

Binary file not shown.

222
claude.md
View File

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

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ 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という機能をつけました。
@@ -17,7 +17,7 @@ $ git clone https://git.syui.ai/ai/log
$ cd log
$ cargo build
$ ./target/debug/ailog init my-blog
$ ./target/debug/ailog server my-blog
$ ./target/debug/ailog serve my-blog
```
## install
@@ -30,7 +30,7 @@ $ export RUSTUP_HOME="$HOME/.rustup"
$ export PATH="$HOME/.cargo/bin:$PATH"
---
$ which ailog
$ ailog
$ ailog -h
```
## build deploy
@@ -57,24 +57,28 @@ $ npm run build
$ npm run preview
```
```sh
```sh:ouath/.env.production
# Production environment variables
VITE_APP_HOST=https://example.com
VITE_OAUTH_CLIENT_ID=https://example.com/client-metadata.json
VITE_OAUTH_REDIRECT_URI=https://example.com/oauth/callback
VITE_APP_HOST=https://syui.ai
VITE_OAUTH_CLIENT_ID=https://syui.ai/client-metadata.json
VITE_OAUTH_REDIRECT_URI=https://syui.ai/oauth/callback
VITE_ADMIN_DID=did:plc:uqzpqmrjnptsxezjx4xuh2mn
# Collection names for OAuth app
VITE_COLLECTION_COMMENT=ai.syui.log
VITE_COLLECTION_USER=ai.syui.log.user
VITE_COLLECTION_CHAT=ai.syui.log.chat
# Base collection (all others are derived via getCollectionNames)
VITE_OAUTH_COLLECTION=ai.syui.log
# Collection names for ailog (backward compatibility)
AILOG_COLLECTION_COMMENT=ai.syui.log
AILOG_COLLECTION_USER=ai.syui.log.user
# AI Configuration
VITE_AI_ENABLED=true
VITE_AI_ASK_AI=true
VITE_AI_PROVIDER=ollama
VITE_AI_MODEL=gemma3:4b
VITE_AI_HOST=https://ollama.syui.ai
VITE_AI_SYSTEM_PROMPT="ai"
VITE_AI_DID=did:plc:4hqjfn7m6n5hno3doamuhgef
# API Configuration
VITE_BSKY_PUBLIC_API=https://public.api.bsky.app
VITE_ATPROTO_API=https://bsky.social
```
これは`ailog oauth build my-blog`で`./my-blog/config.toml`から`./oauth/.env.production`が生成されます。
@@ -115,15 +119,8 @@ $ cloudflared tunnel --config cloudflared-config.yml run
$ cloudflared tunnel route dns ${uuid} example.com
```
以下の2つのcollection recordを生成します。ユーザーには`ai.syui.log`が生成され、ここにコメントが記録されます。それを取得して表示しています。`ai.syui.log.user`は管理者である`VITE_ADMIN_DID`用です。
```sh
VITE_COLLECTION_COMMENT=ai.syui.log
VITE_COLLECTION_USER=ai.syui.log.user
```
```sh
$ ailog auth login
$ ailog auth init
$ ailog stream server
```
@@ -135,8 +132,9 @@ $ ailog stream server
`ask-AI`の仕組みは割愛します。後に変更される可能性が高いと思います。
local llm, mcp, atproto組み合わせです。
`llm`, `mcp`, `atproto`などの組み合わせです。
現在、`/index.json`を監視して、更新があれば、翻訳などを行い自動ポストする機能があります。
## code syntax

View File

@@ -6,10 +6,10 @@ tags: ["blog", "cloudflare", "github"]
draft: false
---
ブログを移行しました。
ブログを移行しました。過去のブログは[syui.github.io](https://syui.github.io)にありあます。
1. `gh-pages`から`cf-pages`への移行になります。
2. `hugo`からの移行で、自作の`ailog`でbuildしています。
2. 自作の`ailog`でbuildしています。
3. 特徴としては、`atproto`, `AI`との連携です。
```yml:.github/workflows/cloudflare-pages.yml
@@ -60,3 +60,7 @@ jobs:
wranglerVersion: '3'
```
## url
- [https://syui.pages.dev](https://syui.pages.dev)
- [https://syui.github.io](https://syui.github.io)

View File

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

View File

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

View File

@@ -17,7 +17,7 @@
/css/*
Content-Type: text/css
Cache-Control: public, max-age=60
Cache-Control: no-cache
/*.js
Content-Type: application/javascript

View File

@@ -1,9 +1,3 @@
# AI機能をai.gpt MCP serverにリダイレクト
/api/ask https://ai-gpt-mcp.syui.ai/ask 200
# Ollama API proxy (Cloudflare Workers)
/api/ollama-proxy https://ollama-proxy.YOUR-SUBDOMAIN.workers.dev/:splat 200
# OAuth routes
/oauth/* /oauth/index.html 200

View File

@@ -1,6 +1,6 @@
{
"client_id": "https://syui.ai/client-metadata.json",
"client_name": "ai.card",
"client_name": "ai.log",
"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
}
}

View File

@@ -59,7 +59,7 @@ a.view-markdown:any-link {
.container {
min-height: 100vh;
display: grid;
grid-template-rows: auto auto 1fr auto;
grid-template-rows: auto 0fr 1fr auto;
grid-template-areas:
"header"
"ask-ai"
@@ -158,6 +158,15 @@ a.view-markdown:any-link {
background: #f6f8fa;
border-bottom: 1px solid #d1d9e0;
padding: 24px;
overflow: hidden;
}
.ask-ai-panel[style*="block"] {
display: block !important;
}
.container:has(.ask-ai-panel[style*="block"]) {
grid-template-rows: auto auto 1fr auto;
}
.ask-ai-content {
@@ -193,13 +202,15 @@ a.view-markdown:any-link {
grid-area: main;
max-width: 1000px;
margin: 0 auto;
padding: 24px;
/* padding: 24px; */
padding-top: 80px;
width: 100%;
}
@media (max-width: 1000px) {
.main-content {
padding: 20px;
/* padding: 20px; */
padding: 0px;
max-width: 100%;
}
}
@@ -237,7 +248,7 @@ a.view-markdown:any-link {
}
.post-title a {
color: #1f2328;
color: var(--theme-color);
text-decoration: none;
font-size: 18px;
font-weight: 600;
@@ -324,6 +335,10 @@ a.view-markdown:any-link {
margin: 0 auto;
}
article.article-content {
padding: 10px;
}
.article-meta {
display: flex;
gap: 16px;
@@ -348,6 +363,7 @@ a.view-markdown:any-link {
.article-actions {
display: flex;
gap: 12px;
padding: 15px 0;
}
.action-btn {
@@ -781,7 +797,7 @@ a.view-markdown:any-link {
@media (max-width: 1000px) {
.main-header {
padding: 12px 0;
padding: 0px;
}
.header-content {
@@ -791,6 +807,48 @@ a.view-markdown:any-link {
gap: 0;
}
/* OAuth app mobile fixes */
.comment-item {
padding: 0px !important;
margin: 0px !important;
}
.auth-section {
padding: 0px !important;
}
.comments-list {
padding: 0px !important;
}
.comment-section {
max-width: 100% !important;
padding: 0px !important;
margin: 0px !important;
}
.comment-container {
max-width: 100% !important;
padding: 0px !important;
margin: 0px !important;
}
.comment-content {
padding: 10px !important;
word-wrap: break-word !important;
overflow-wrap: break-word !important;
}
.comment-header {
padding: 10px !important;
}
/* Fix comment-meta URI overflow */
.comment-meta {
word-break: break-all !important;
overflow-wrap: break-word !important;
}
/* Hide site title text on mobile */
.site-title {
display: none;
@@ -818,13 +876,13 @@ a.view-markdown:any-link {
justify-self: end;
}
/* Ask AI button mobile style */
/* Ask AI button mobile style - icon only */
.ask-ai-btn {
padding: 8px;
min-width: 40px;
justify-content: center;
font-size: 0;
gap: 0;
font-size: 0; /* Hide all text content */
}
.ask-ai-btn .ai-icon {
@@ -881,11 +939,11 @@ a.view-markdown:any-link {
padding: 16px;
}
.article-title {
font-size: 24px;
}
.article-title {
font-size: 24px;
padding: 30px 0px;
}
.message-header .avatar {
width: 32px;
height: 32px;
@@ -902,4 +960,4 @@ a.view-markdown:any-link {
width: 100%;
padding: 0;
}
}
}

View File

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

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

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

View File

@@ -1,360 +1,295 @@
/**
* Ask AI functionality - Pure JavaScript, no jQuery dependency
* Ask AI functionality - Based on original working implementation
*/
class AskAI {
constructor() {
this.isReady = false;
this.aiProfile = null;
this.init();
// 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();
}
}
init() {
this.setupEventListeners();
this.checkAuthOnLoad();
}
setupEventListeners() {
// Listen for AI ready signal
window.addEventListener('aiChatReady', () => {
this.isReady = true;
console.log('AI Chat is ready');
});
// Listen for AI profile updates
window.addEventListener('aiProfileLoaded', (event) => {
this.aiProfile = event.detail;
console.log('AI profile loaded:', this.aiProfile);
this.updateButton();
});
// Listen for AI responses
window.addEventListener('aiResponseReceived', (event) => {
this.handleAIResponse(event.detail);
});
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
this.hide();
}
if (e.key === 'Enter' && e.target.id === 'aiQuestion' && !e.shiftKey) {
e.preventDefault();
this.ask();
}
});
// Monitor authentication changes
this.observeAuth();
}
toggle() {
const panel = document.getElementById('askAiPanel');
const isVisible = panel.style.display !== 'none';
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';
if (isVisible) {
this.hide();
} else {
this.show();
}
}
show() {
const panel = document.getElementById('askAiPanel');
panel.style.display = 'block';
this.checkAuth();
}
hide() {
const panel = document.getElementById('askAiPanel');
panel.style.display = 'none';
}
checkAuth() {
const userSections = document.querySelectorAll('.user-section');
const isAuthenticated = userSections.length > 0;
const authCheck = document.getElementById('authCheck');
const chatForm = document.getElementById('chatForm');
// Show initial greeting if chat history is empty
const chatHistory = document.getElementById('chatHistory');
if (isAuthenticated) {
authCheck.style.display = 'none';
chatForm.style.display = 'block';
chatHistory.style.display = 'block';
if (chatHistory.children.length === 0) {
this.showGreeting();
}
setTimeout(() => {
document.getElementById('aiQuestion').focus();
}, 50);
} else {
authCheck.style.display = 'block';
chatForm.style.display = 'none';
chatHistory.style.display = 'none';
if (chatHistory.children.length === 0) {
showInitialGreeting();
}
}
checkAuthOnLoad() {
// Focus on input
setTimeout(() => {
this.checkAuth();
}, 500);
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';
}
}
observeAuth() {
const observer = new MutationObserver(() => {
const userSections = document.querySelectorAll('.user-section');
if (userSections.length > 0) {
this.checkAuth();
observer.disconnect();
}
});
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);
observer.observe(document.body, {
childList: true,
subtree: true
});
// 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';
}
}
updateButton() {
const button = document.getElementById('askAiButton');
if (this.aiProfile && this.aiProfile.displayName) {
const textNode = button.childNodes[2];
if (textNode) {
textNode.textContent = this.aiProfile.displayName;
}
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('@', '');
}
}
showGreeting() {
if (!this.aiProfile) return;
const chatHistory = document.getElementById('chatHistory');
const greetingDiv = document.createElement('div');
greetingDiv.className = 'chat-message ai-message comment-style initial-greeting';
const avatarElement = this.aiProfile.avatar
? `<img src="${this.aiProfile.avatar}" alt="${this.aiProfile.displayName}" class="profile-avatar">`
: '🤖';
greetingDiv.innerHTML = `
<div class="message-header">
<div class="avatar">${avatarElement}</div>
<div class="user-info">
<div class="display-name">${this.aiProfile.displayName}</div>
<div class="handle">@${this.aiProfile.handle}</div>
<div class="timestamp">${new Date().toLocaleString()}</div>
</div>
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 class="message-content">
Hello! I'm an AI assistant trained on this blog's content. I can answer questions about the articles, provide insights, and help you understand the topics discussed here. What would you like to know?
</div>
<div class="message-content">${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>
`;
chatHistory.appendChild(greetingDiv);
}
</div>
<div class="message-content">${message}</div>
`;
chatHistory.appendChild(errorDiv);
}
async ask() {
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 (!this.isReady) {
await this.waitForReady();
}
// Disable button
askButton.disabled = true;
askButton.textContent = 'Posting...';
try {
// Add user message
this.addUserMessage(question);
// Clear input
document.getElementById('aiQuestion').value = '';
// Show loading
this.showLoading();
// Post question
const event = new CustomEvent('postAIQuestion', {
detail: { question: question }
});
window.dispatchEvent(event);
} catch (error) {
this.showError('Sorry, I encountered an error. Please try again.');
} finally {
askButton.disabled = false;
askButton.textContent = 'Ask';
}
function removeLoadingMessage() {
const loadingMsg = document.querySelector('.ai-loading-simple');
if (loadingMsg) {
loadingMsg.remove();
}
}
waitForReady() {
return new Promise(resolve => {
const checkReady = setInterval(() => {
if (this.isReady) {
clearInterval(checkReady);
resolve();
}
}, 100);
});
}
function showInitialGreeting() {
if (!aiProfileData) return;
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>
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 class="message-content">${question}</div>
`;
chatHistory.appendChild(questionDiv);
}
</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);
}
showLoading() {
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);
}
showError(message) {
const chatHistory = document.getElementById('chatHistory');
this.removeLoading();
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);
}
removeLoading() {
const loadingMsg = document.querySelector('.ai-loading-simple');
if (loadingMsg) {
loadingMsg.remove();
}
}
handleAIResponse(responseData) {
const chatHistory = document.getElementById('chatHistory');
this.removeLoading();
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
this.limitChatHistory();
}
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]);
}
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;
}
}
}
// Initialize Ask AI when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
try {
window.askAIInstance = new AskAI();
console.log('Ask AI initialized successfully');
} catch (error) {
console.error('Failed to initialize Ask AI:', error);
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 function for onclick
window.AskAI = {
toggle: function() {
console.log('AskAI.toggle called');
if (window.askAIInstance) {
window.askAIInstance.toggle();
} else {
console.error('Ask AI instance not available');
}
},
ask: function() {
console.log('AskAI.ask called');
if (window.askAIInstance) {
window.askAIInstance.ask();
} else {
console.error('Ask AI instance not available');
}
}
};
// Global functions for onclick handlers
window.toggleAskAI = toggleAskAI;
window.askQuestion = askQuestion;

View File

@@ -15,7 +15,6 @@
<link rel="stylesheet" href="/pkg/icomoon/style.css">
<link rel="stylesheet" href="/pkg/font-awesome/css/all.min.css">
{% include "oauth-assets.html" %}
{% block head %}{% endblock %}
</head>
<body>
@@ -50,7 +49,7 @@
</a>
</div>
<div class="header-actions">
<button class="ask-ai-btn" onclick="AskAI.toggle()" id="askAiButton">
<button class="ask-ai-btn" onclick="toggleAskAI()" id="askAiButton">
<span class="ai-icon icon-ai"></span>
ai
</button>
@@ -67,7 +66,7 @@
<div id="chatForm" class="ask-ai-form" style="display: none;">
<input type="text" id="aiQuestion" placeholder="What would you like to know?" />
<button onclick="AskAI.ask()" id="askButton">Ask</button>
<button onclick="askQuestion()" id="askButton">Ask</button>
</div>
<div id="chatHistory" class="chat-history" style="display: none;"></div>
@@ -83,7 +82,7 @@
<footer class="main-footer">
<div class="footer-social">
<a href="https://web.syu.is/@syui" target="_blank"><i class="fab fa-bluesky"></i></a>
<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>
@@ -92,5 +91,7 @@
<script src="/js/ask-ai.js"></script>
<script src="/js/theme.js"></script>
{% include "oauth-assets.html" %}
</body>
</html>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { User } from '../services/auth';
import { atprotoOAuthService } from '../services/atproto-oauth';
import { appConfig } from '../config/app';
import { appConfig, getCollectionNames } from '../config/app';
interface AIChatProps {
user: User | null;
@@ -14,26 +14,22 @@ export const AIChat: React.FC<AIChatProps> = ({ user, isEnabled }) => {
const [isProcessing, setIsProcessing] = useState(false);
const [aiProfile, setAiProfile] = useState<any>(null);
// Get AI settings from environment variables
// Get AI settings from appConfig (unified configuration)
const aiConfig = {
enabled: import.meta.env.VITE_AI_ENABLED === 'true',
askAi: import.meta.env.VITE_AI_ASK_AI === 'true',
provider: import.meta.env.VITE_AI_PROVIDER || 'ollama',
model: import.meta.env.VITE_AI_MODEL || 'gemma3:4b',
host: import.meta.env.VITE_AI_HOST || 'https://ollama.syui.ai',
systemPrompt: import.meta.env.VITE_AI_SYSTEM_PROMPT || 'You are a helpful AI assistant trained on this blog\'s content.',
aiDid: import.meta.env.VITE_AI_DID || 'did:plc:uqzpqmrjnptsxezjx4xuh2mn',
bskyPublicApi: import.meta.env.VITE_BSKY_PUBLIC_API || 'https://public.api.bsky.app',
enabled: appConfig.aiEnabled,
askAi: appConfig.aiAskAi,
provider: appConfig.aiProvider,
model: appConfig.aiModel,
host: appConfig.aiHost,
systemPrompt: appConfig.aiSystemPrompt,
aiDid: appConfig.aiDid,
bskyPublicApi: appConfig.bskyPublicApi,
};
// Fetch AI profile on load
useEffect(() => {
const fetchAIProfile = async () => {
console.log('=== AI PROFILE FETCH START ===');
console.log('AI DID:', aiConfig.aiDid);
if (!aiConfig.aiDid) {
console.log('No AI DID configured');
return;
}
@@ -41,9 +37,7 @@ export const AIChat: React.FC<AIChatProps> = ({ user, isEnabled }) => {
// Try with agent first
const agent = atprotoOAuthService.getAgent();
if (agent) {
console.log('Fetching AI profile with agent for DID:', aiConfig.aiDid);
const profile = await agent.getProfile({ actor: aiConfig.aiDid });
console.log('AI profile fetched successfully:', profile.data);
const profileData = {
did: aiConfig.aiDid,
handle: profile.data.handle,
@@ -51,21 +45,17 @@ export const AIChat: React.FC<AIChatProps> = ({ user, isEnabled }) => {
avatar: profile.data.avatar,
description: profile.data.description
};
console.log('Setting aiProfile to:', profileData);
setAiProfile(profileData);
// Dispatch event to update Ask AI button
window.dispatchEvent(new CustomEvent('aiProfileLoaded', { detail: profileData }));
console.log('=== AI PROFILE FETCH SUCCESS (AGENT) ===');
return;
}
// Fallback to public API
console.log('No agent available, trying public API for AI profile');
const response = await fetch(`${aiConfig.bskyPublicApi}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(aiConfig.aiDid)}`);
if (response.ok) {
const profileData = await response.json();
console.log('AI profile fetched via public API:', profileData);
const profile = {
did: aiConfig.aiDid,
handle: profileData.handle,
@@ -73,21 +63,15 @@ export const AIChat: React.FC<AIChatProps> = ({ user, isEnabled }) => {
avatar: profileData.avatar,
description: profileData.description
};
console.log('Setting aiProfile to:', profile);
setAiProfile(profile);
// Dispatch event to update Ask AI button
window.dispatchEvent(new CustomEvent('aiProfileLoaded', { detail: profile }));
console.log('=== AI PROFILE FETCH SUCCESS (PUBLIC API) ===');
return;
} else {
console.error('Public API failed with status:', response.status);
}
} catch (error) {
console.error('Failed to fetch AI profile:', error);
setAiProfile(null);
}
console.log('=== AI PROFILE FETCH FAILED ===');
};
fetchAIProfile();
@@ -100,9 +84,6 @@ export const AIChat: React.FC<AIChatProps> = ({ user, isEnabled }) => {
const handleAIQuestion = async (event: any) => {
if (!user || !event.detail || !event.detail.question || isProcessing || !aiProfile) return;
console.log('AIChat received question:', event.detail.question);
console.log('Current aiProfile state:', aiProfile);
setIsProcessing(true);
try {
await postQuestionAndGenerateResponse(event.detail.question);
@@ -114,7 +95,6 @@ export const AIChat: React.FC<AIChatProps> = ({ user, isEnabled }) => {
// Add listener with a small delay to ensure it's ready
setTimeout(() => {
window.addEventListener('postAIQuestion', handleAIQuestion);
console.log('AIChat event listener registered');
// Notify that AI is ready
window.dispatchEvent(new CustomEvent('aiChatReady'));
@@ -134,40 +114,50 @@ export const AIChat: React.FC<AIChatProps> = ({ user, isEnabled }) => {
const agent = atprotoOAuthService.getAgent();
if (!agent) throw new Error('No agent available');
// Get collection names
const collections = getCollectionNames(appConfig.collections.base);
// 1. Post question to ATProto
const now = new Date();
const rkey = now.toISOString().replace(/[:.]/g, '-');
// Extract post metadata from current page
const currentUrl = window.location.href;
const postSlug = currentUrl.match(/\/posts\/([^/]+)/)?.[1] || '';
const postTitle = document.title.replace(' - syui.ai', '') || '';
const questionRecord = {
$type: appConfig.collections.chat,
question: question,
url: window.location.href,
createdAt: now.toISOString(),
$type: collections.chat,
post: {
url: currentUrl,
slug: postSlug,
title: postTitle,
date: new Date().toISOString(),
tags: [],
language: "ja"
},
type: "question",
text: question,
author: {
did: user.did,
handle: user.handle,
avatar: user.avatar,
displayName: user.displayName || user.handle,
},
context: {
page_title: document.title,
page_url: window.location.href,
},
createdAt: now.toISOString(),
};
await agent.api.com.atproto.repo.putRecord({
repo: user.did,
collection: appConfig.collections.chat,
collection: collections.chat,
rkey: rkey,
record: questionRecord,
});
console.log('Question posted to ATProto');
// 2. Get chat history
const chatRecords = await agent.api.com.atproto.repo.listRecords({
repo: user.did,
collection: appConfig.collections.chat,
collection: collections.chat,
limit: 10,
});
@@ -175,10 +165,10 @@ export const AIChat: React.FC<AIChatProps> = ({ user, isEnabled }) => {
if (chatRecords.data.records) {
chatHistoryText = chatRecords.data.records
.map((r: any) => {
if (r.value.question) {
return `User: ${r.value.question}`;
} else if (r.value.answer) {
return `AI: ${r.value.answer}`;
if (r.value.type === 'question') {
return `User: ${r.value.text}`;
} else if (r.value.type === 'answer') {
return `AI: ${r.value.text}`;
}
return '';
})
@@ -209,7 +199,7 @@ Answer:`;
options: {
temperature: 0.9,
top_p: 0.9,
num_predict: 80, // Shorter responses for faster generation
num_predict: 200, // Longer responses for better answers
repeat_penalty: 1.1,
}
}),
@@ -235,37 +225,38 @@ Answer:`;
// 5. Save AI response in background
const answerRkey = now.toISOString().replace(/[:.]/g, '-') + '-answer';
console.log('=== SAVING AI ANSWER ===');
console.log('Current aiProfile:', aiProfile);
const answerRecord = {
$type: appConfig.collections.chat,
answer: aiAnswer,
question_rkey: rkey,
url: window.location.href,
createdAt: now.toISOString(),
$type: collections.chat,
post: {
url: currentUrl,
slug: postSlug,
title: postTitle,
date: new Date().toISOString(),
tags: [],
language: "ja"
},
type: "answer",
text: aiAnswer,
author: {
did: aiProfile.did,
handle: aiProfile.handle,
displayName: aiProfile.displayName,
avatar: aiProfile.avatar,
},
createdAt: now.toISOString(),
};
console.log('Answer record to save:', answerRecord);
// Save to ATProto asynchronously (don't wait for it)
agent.api.com.atproto.repo.putRecord({
repo: user.did,
collection: appConfig.collections.chat,
collection: collections.chat,
rkey: answerRkey,
record: answerRecord,
}).catch(err => {
console.error('Failed to save AI response to ATProto:', err);
// Silent fail for AI response saving
});
} catch (error) {
console.error('Failed to generate AI response:', error);
window.dispatchEvent(new CustomEvent('aiResponseError', {
detail: { error: 'AI応答の生成に失敗しました' }
}));

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -31,51 +31,48 @@ class AtprotoOAuthService {
private async _doInitialize(): Promise<void> {
try {
console.log('=== INITIALIZING ATPROTO OAUTH CLIENT ===');
// Generate client ID based on current origin
const clientId = this.getClientId();
console.log('Client ID:', clientId);
// Support multiple PDS hosts for OAuth
console.log('[OAuth Debug] Initializing OAuth client with default settings');
this.oauthClient = await BrowserOAuthClient.load({
clientId: clientId,
handleResolver: 'https://bsky.social', // Default resolver
plcDirectoryUrl: 'https://plc.directory', // Default PLC directory
});
console.log('[OAuth Debug] OAuth client initialized with defaults');
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,36 +80,36 @@ 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;
@@ -121,23 +118,24 @@ class AtprotoOAuthService {
// Create Agent directly with session (per official docs)
try {
this.agent = new Agent(session);
console.log('Agent created directly with session');
console.log('[OAuth Debug] Agent created successfully 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);
console.log('[OAuth Debug] Agent has session:', this.agent.session);
}
} catch (err) {
console.log('Failed to create Agent with session directly, trying dpopFetch method');
console.log('[OAuth Debug] Failed to create agent with session:', err);
// Fallback to dpopFetch method
this.agent = new Agent({
service: session.server?.serviceEndpoint || 'https://bsky.social',
fetch: session.dpopFetch
});
try {
this.agent = new Agent({
service: session.server?.serviceEndpoint || 'https://bsky.social',
fetch: session.dpopFetch
});
console.log('[OAuth Debug] Agent created with dpopFetch fallback');
} catch (fallbackErr) {
console.error('[OAuth Debug] Failed to create agent with fallback:', fallbackErr);
}
}
// Store basic session info
@@ -145,7 +143,7 @@ class AtprotoOAuthService {
// If handle is missing, try multiple methods to resolve it
if (!handle || handle === 'unknown') {
console.log('Handle not in session, attempting to resolve...');
// Method 1: Try using the agent to get profile
try {
@@ -154,11 +152,11 @@ class AtprotoOAuthService {
if (profile.data.handle) {
handle = profile.data.handle;
(this as any)._sessionInfo.handle = handle;
console.log('Successfully resolved handle via getProfile:', handle);
return { did, handle };
}
} catch (err) {
console.error('getProfile failed:', err);
}
// Method 2: Try using describeRepo
@@ -169,18 +167,20 @@ class AtprotoOAuthService {
if (repoDesc.data.handle) {
handle = repoDesc.data.handle;
(this as any)._sessionInfo.handle = handle;
console.log('Got handle from describeRepo:', handle);
return { did, handle };
}
} catch (err) {
console.error('describeRepo failed:', err);
}
// Method 3: Hardcoded fallback for known DIDs
if (did === 'did:plc:uqzpqmrjnptsxezjx4xuh2mn') {
handle = 'syui.ai';
// Method 3: Fallback for admin DID
const adminDid = import.meta.env.VITE_ADMIN_DID;
if (did === adminDid) {
const appHost = import.meta.env.VITE_APP_HOST || 'https://syui.ai';
handle = new URL(appHost).hostname;
(this as any)._sessionInfo.handle = handle;
console.log('Using hardcoded handle for known DID');
}
}
@@ -191,7 +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;
}
@@ -200,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
}
@@ -208,34 +208,56 @@ class AtprotoOAuthService {
return `${origin}/client-metadata.json`;
}
private detectPDSFromHandle(handle: string): string {
console.log('Detecting PDS for handle:', handle);
private async detectPDSFromHandle(handle: string): Promise<string> {
// Handle detection for OAuth PDS routing
// Supported PDS hosts and their corresponding handles
// Check if handle ends with known PDS domains first
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}`);
// Using PDS for domain match
return pdsUrl;
}
}
// For handles that don't match domain patterns, resolve via API
try {
// Try to resolve handle to get the actual PDS
const endpoints = ['https://syu.is', 'https://bsky.social'];
for (const endpoint of endpoints) {
try {
const response = await fetch(`${endpoint}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`);
if (response.ok) {
const data = await response.json();
if (data.did) {
console.log('[OAuth Debug] Resolved handle via', endpoint, '- using that PDS');
return endpoint;
}
}
} catch (e) {
continue;
}
}
} catch (e) {
console.log('[OAuth Debug] Handle resolution failed, using default');
}
// Default to bsky.social
console.log(`Handle ${handle} using default PDS: https://bsky.social`);
// Using default bsky.social
return 'https://bsky.social';
}
async initiateOAuthFlow(handle?: string): Promise<void> {
try {
console.log('=== INITIATING OAUTH FLOW ===');
if (!this.oauthClient) {
console.log('OAuth client not initialized, initializing now...');
await this.initialize();
}
@@ -251,75 +273,71 @@ class AtprotoOAuthService {
}
}
console.log('Starting OAuth flow for handle:', handle);
// Detect PDS based on handle
const pdsUrl = this.detectPDSFromHandle(handle);
console.log('Detected PDS for handle:', { handle, pdsUrl });
const pdsUrl = await this.detectPDSFromHandle(handle);
console.log('[OAuth Debug] 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,
});
// Always re-initialize OAuth client with detected PDS
console.log('[OAuth Debug] Re-initializing OAuth client');
// Clear existing client to force fresh initialization
this.oauthClient = null;
this.initializePromise = null;
// Determine PLC directory based on input handle, not environment PDS
let plcDirectoryUrl = 'https://plc.directory'; // Default to Bluesky PLC
if (handle.endsWith('.syu.is') || handle.endsWith('.syui.ai')) {
plcDirectoryUrl = 'https://plc.syu.is';
}
console.log('[OAuth Debug] Using PLC directory:', plcDirectoryUrl);
this.oauthClient = await BrowserOAuthClient.load({
clientId: this.getClientId(),
handleResolver: pdsUrl,
plcDirectoryUrl: plcDirectoryUrl,
});
console.log('[OAuth Debug] OAuth client re-initialized successfully');
// OAuth client initialized
// Start OAuth authorization flow
console.log('Calling oauthClient.authorize with handle:', handle);
try {
// Starting OAuth authorization
// Use handle directly since PLC directory is now correctly configured
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
});
// Authorization failed
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();
}
@@ -327,11 +345,11 @@ class AtprotoOAuthService {
throw new Error('Failed to initialize OAuth client');
}
console.log('OAuth client ready, initializing to process callback...');
// Call init() again to process the callback URL
const result = await this.oauthClient.init();
console.log('OAuth callback processing result:', result);
if (result?.session) {
// Process the session
@@ -339,56 +357,54 @@ 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 ===');
console.log('[OAuth Debug] Checking session...');
if (!this.oauthClient) {
console.log('No OAuth client, initializing...');
console.log('[OAuth Debug] No OAuth client, initializing...');
await this.initialize();
}
if (!this.oauthClient) {
console.log('OAuth client initialization failed');
console.log('[OAuth Debug] Failed to initialize OAuth client');
return null;
}
console.log('Running oauthClient.init() to check session...');
const result = await this.oauthClient.init();
console.log('oauthClient.init() result:', result);
console.log('[OAuth Debug] OAuth init result:', !!result?.session);
if (result?.session) {
console.log('[OAuth Debug] Session found, processing...');
// Use the common session processing method
return this.processSession(result.session);
const sessionData = await this.processSession(result.session);
console.log('[OAuth Debug] Processed session data:', sessionData);
return sessionData;
}
console.log('[OAuth Debug] No session found');
return null;
} catch (error) {
console.error('Session check failed:', error);
return null;
}
}
@@ -398,13 +414,7 @@ class AtprotoOAuthService {
}
getSession(): AtprotoSession | null {
console.log('getSession called');
console.log('Current state:', {
hasAgent: !!this.agent,
hasAgentSession: !!this.agent?.session,
hasOAuthClient: !!this.oauthClient,
hasSessionInfo: !!(this as any)._sessionInfo
});
// First check if we have an agent with session
if (this.agent?.session) {
@@ -414,7 +424,7 @@ class AtprotoOAuthService {
accessJwt: this.agent.session.accessJwt || '',
refreshJwt: this.agent.session.refreshJwt || '',
};
console.log('Returning agent session:', session);
return session;
}
@@ -426,11 +436,11 @@ class AtprotoOAuthService {
accessJwt: 'dpop-protected', // Indicate that tokens are handled by dpopFetch
refreshJwt: 'dpop-protected',
};
console.log('Returning stored session info:', session);
return session;
}
console.log('No session available');
return null;
}
@@ -450,28 +460,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) {
console.error('OAuth client logout error:', oauthError);
}
// Reset the OAuth client to force re-initialization
@@ -492,11 +502,11 @@ class AtprotoOAuthService {
}
}
keysToRemove.forEach(key => {
console.log('Removing localStorage key:', key);
localStorage.removeItem(key);
});
console.log('=== LOGOUT COMPLETED ===');
// Force page reload to ensure clean state
setTimeout(() => {
@@ -504,7 +514,7 @@ class AtprotoOAuthService {
}, 100);
} catch (error) {
console.error('Logout failed:', error);
}
}
@@ -519,8 +529,8 @@ class AtprotoOAuthService {
const did = sessionInfo.did;
try {
console.log('Saving cards to atproto collection...');
console.log('Using DID:', did);
// Ensure we have a fresh agent
if (!this.agent) {
@@ -550,13 +560,6 @@ class AtprotoOAuthService {
createdAt: createdAt
};
console.log('PutRecord request:', {
repo: did,
collection: collection,
rkey: rkey,
record: record
});
// Use Agent's com.atproto.repo.putRecord method
const response = await this.agent.com.atproto.repo.putRecord({
@@ -566,9 +569,9 @@ class AtprotoOAuthService {
record: record
});
console.log('カードデータをai.card.boxに保存しました:', response);
} catch (error) {
console.error('カードボックス保存エラー:', error);
throw error;
}
}
@@ -584,8 +587,8 @@ class AtprotoOAuthService {
const did = sessionInfo.did;
try {
console.log('Fetching cards from atproto collection...');
console.log('Using DID:', did);
// Ensure we have a fresh agent
if (!this.agent) {
@@ -598,7 +601,7 @@ class AtprotoOAuthService {
rkey: 'self'
});
console.log('Cards from box response:', response);
// Convert to expected format
const result = {
@@ -611,7 +614,7 @@ class AtprotoOAuthService {
return result;
} catch (error) {
console.error('カードボックス取得エラー:', error);
// If record doesn't exist, return empty
if (error.toString().includes('RecordNotFound')) {
@@ -633,8 +636,8 @@ class AtprotoOAuthService {
const did = sessionInfo.did;
try {
console.log('Deleting card box collection...');
console.log('Using DID:', did);
// Ensure we have a fresh agent
if (!this.agent) {
@@ -647,33 +650,35 @@ class AtprotoOAuthService {
rkey: 'self'
});
console.log('Card box deleted successfully:', response);
} catch (error) {
console.error('カードボックス削除エラー:', error);
throw error;
}
}
// 手動でトークンを設定(開発・デバッグ用)
setManualTokens(accessJwt: string, refreshJwt: string): void {
console.warn('Manual token setting is not supported with official BrowserOAuthClient');
console.warn('Please use the proper OAuth flow instead');
// For backward compatibility, store in localStorage
const adminDid = import.meta.env.VITE_ADMIN_DID || 'did:plc:unknown';
const appHost = import.meta.env.VITE_APP_HOST || 'https://example.com';
const session: AtprotoSession = {
did: 'did:plc:uqzpqmrjnptsxezjx4xuh2mn',
handle: 'syui.ai',
did: adminDid,
handle: new URL(appHost).hostname,
accessJwt: accessJwt,
refreshJwt: refreshJwt
};
localStorage.setItem('atproto_session', JSON.stringify(session));
console.log('Manual tokens stored in localStorage for backward compatibility');
}
// 後方互換性のための従来関数
saveSessionToStorage(session: AtprotoSession): void {
console.warn('saveSessionToStorage is deprecated with BrowserOAuthClient');
localStorage.setItem('atproto_session', JSON.stringify(session));
}

View File

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

View File

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

View File

@@ -53,7 +53,6 @@ export class OAuthEndpointHandler {
}
});
} catch (error) {
console.error('Failed to generate JWKS:', error);
return new Response(JSON.stringify({ error: 'Failed to generate JWKS' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
@@ -62,7 +61,6 @@ export class OAuthEndpointHandler {
}
} catch (e) {
// If URL parsing fails, pass through to original fetch
console.debug('URL parsing failed, passing through:', e);
}
// Pass through all other requests
@@ -136,6 +134,5 @@ export function registerOAuthServiceWorker() {
const blob = new Blob([swCode], { type: 'application/javascript' });
const swUrl = URL.createObjectURL(blob);
navigator.serviceWorker.register(swUrl).catch(console.error);
}
}

View File

@@ -37,7 +37,6 @@ export class OAuthKeyManager {
this.keyPair = await this.importKeyPair(keyData);
return this.keyPair;
} catch (error) {
console.warn('Failed to load stored key, generating new one:', error);
localStorage.removeItem('oauth_private_key');
}
}
@@ -115,7 +114,6 @@ export class OAuthKeyManager {
const privateKey = await window.crypto.subtle.exportKey('jwk', keyPair.privateKey);
localStorage.setItem('oauth_private_key', JSON.stringify(privateKey));
} catch (error) {
console.error('Failed to store private key:', error);
}
}

View File

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

View File

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

173
scpt/ailog-generate.zsh Executable file
View File

@@ -0,0 +1,173 @@
#!/bin/zsh
# Generate AI content for blog posts
# Usage: ./bin/ailog-generate.zsh [md-file]
set -e
# Load configuration
f=~/.config/syui/ai/bot/token.json
# Default values
default_pds="bsky.social"
default_did=`cat $f|jq -r .did`
default_token=`cat $f|jq -r .accessJwt`
default_refresh=`cat $f|jq -r .refreshJwt`
# Refresh token if needed
curl -sL -X POST -H "Content-Type: application/json" -H "Authorization: Bearer $default_refresh" https://$default_pds/xrpc/com.atproto.server.refreshSession >! $f
default_token=`cat $f|jq -r .accessJwt`
# Set variables
admin_did=$default_did
admin_token=$default_token
ai_did="did:plc:4hqjfn7m6n5hno3doamuhgef"
ollama_host="https://ollama.syui.ai"
blog_host="https://syui.ai"
pds=$default_pds
# Parse arguments
md_file=$1
# Function to generate content using Ollama
generate_ai_content() {
local content=$1
local prompt_type=$2
local model="gemma3:4b"
case $prompt_type in
"translate")
prompt="Translate the following Japanese blog post to English. Keep the technical terms and code blocks intact:\n\n$content"
;;
"comment")
prompt="Read this blog post and provide an insightful comment about it. Focus on the key points and add your perspective:\n\n$content"
;;
esac
response=$(curl -sL -X POST "$ollama_host/api/generate" \
-H "Content-Type: application/json" \
-d "{
\"model\": \"$model\",
\"prompt\": \"$prompt\",
\"stream\": false,
\"options\": {
\"temperature\": 0.9,
\"top_p\": 0.9,
\"num_predict\": 500
}
}")
echo "$response" | jq -r '.response'
}
# Function to put record to ATProto
put_record() {
local collection=$1
local rkey=$2
local record=$3
curl -sL -X POST "https://$pds/xrpc/com.atproto.repo.putRecord" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $admin_token" \
-d "{
\"repo\": \"$admin_did\",
\"collection\": \"$collection\",
\"rkey\": \"$rkey\",
\"record\": $record
}"
}
# Function to process a single markdown file
process_md_file() {
local md_path=$1
local filename=$(basename "$md_path" .md)
local content=$(cat "$md_path")
local post_url="$blog_host/posts/$filename"
local rkey=$filename
local now=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
echo "Processing: $md_path"
echo "Post URL: $post_url"
# Generate English translation
echo "Generating English translation..."
en_translation=$(generate_ai_content "$content" "translate")
if [ -n "$en_translation" ]; then
lang_record="{
\"\$type\": \"ai.syui.log.chat.lang\",
\"type\": \"en\",
\"body\": $(echo "$en_translation" | jq -Rs .),
\"url\": \"$post_url\",
\"createdAt\": \"$now\",
\"author\": {
\"did\": \"$ai_did\",
\"handle\": \"yui.syui.ai\",
\"displayName\": \"AI Translator\"
}
}"
echo "Saving translation to ATProto..."
put_record "ai.syui.log.chat.lang" "$rkey" "$lang_record"
fi
# Generate AI comment
echo "Generating AI comment..."
ai_comment=$(generate_ai_content "$content" "comment")
if [ -n "$ai_comment" ]; then
comment_record="{
\"\$type\": \"ai.syui.log.chat.comment\",
\"type\": \"push\",
\"body\": $(echo "$ai_comment" | jq -Rs .),
\"url\": \"$post_url\",
\"createdAt\": \"$now\",
\"author\": {
\"did\": \"$ai_did\",
\"handle\": \"yui.syui.ai\",
\"displayName\": \"AI Commenter\"
}
}"
echo "Saving comment to ATProto..."
put_record "ai.syui.log.chat.comment" "$rkey" "$comment_record"
fi
echo "Completed: $filename"
echo
}
# Main logic
if [ -n "$md_file" ]; then
# Process specific file
if [ -f "$md_file" ]; then
process_md_file "$md_file"
else
echo "Error: File not found: $md_file"
exit 1
fi
else
# Process all new posts
echo "Checking for posts without AI content..."
# Get existing records
existing_langs=$(curl -sL "https://$pds/xrpc/com.atproto.repo.listRecords?repo=$admin_did&collection=ai.syui.log.chat.lang&limit=100" | jq -r '.records[]?.value.url' | sort | uniq)
# Process each markdown file
for md in my-blog/content/posts/*.md; do
if [ -f "$md" ]; then
filename=$(basename "$md" .md)
post_url="$blog_host/posts/$filename"
# Check if already processed
if echo "$existing_langs" | grep -q "$post_url"; then
echo "Skip (already processed): $filename"
else
process_md_file "$md"
sleep 2 # Rate limiting
fi
fi
done
fi
echo "All done!"

30
scpt/delete-chat-records.zsh Executable file
View File

@@ -0,0 +1,30 @@
#!/bin/zsh
set -e
cb=ai.syui.log
cl=( $cb.user )
f=~/.config/syui/ai/log/config.json
default_collection="ai.syui.log.chat.comment"
default_pds="syu.is"
default_did=`cat $f|jq -r .admin.did`
default_token=`cat $f|jq -r .admin.access_jwt`
default_refresh=`cat $f|jq -r .admin.refresh_jwt`
#curl -sL -X POST -H "Content-Type: application/json" -H "Authorization: Bearer $default_refresh" https://$default_pds/xrpc/com.atproto.server.refreshSession >! $f
#default_token=`cat $f|jq -r .admin.access_jwt`
collection=${1:-$default_collection}
pds=${2:-$default_pds}
did=${3:-$default_did}
token=${4:-$default_token}
req=com.atproto.repo.deleteRecord
url=https://$pds/xrpc/$req
for i in $cl; do
echo $i
rkeys=($(curl -sL "https://$default_pds/xrpc/com.atproto.repo.listRecords?repo=$did&collection=$i&limit=100"|jq -r ".records[]?.uri"|cut -d '/' -f 5))
for rkey in "${rkeys[@]}"; do
echo $rkey
json="{\"collection\":\"$i\", \"rkey\":\"$rkey\", \"repo\":\"$did\"}"
curl -sL -X POST -H "Content-Type: application/json" -H "Authorization: Bearer $token" -d "$json" $url
done
done

View File

@@ -2,11 +2,11 @@
function _env() {
d=${0:a:h}
ailog=$d/target/release/ailog
ailog=$d/target/debug/ailog
oauth=$d/oauth
myblog=$d/my-blog
port=4173
source $oauth/.env.production
#source $oauth/.env.production
case $OSTYPE in
darwin*)
export NVM_DIR="$HOME/.nvm"
@@ -16,10 +16,14 @@ function _env() {
esac
}
function _deploy_ailog() {
}
function _server() {
lsof -ti:$port | xargs kill -9 2>/dev/null || true
cd $d/my-blog
cargo build --release
cargo build
cp -rf $ailog $CARGO_HOME/bin/
$ailog build
$ailog serve --port $port
}
@@ -40,7 +44,8 @@ function _oauth_build() {
}
function _server_comment() {
cargo build --release
cargo build
cp -rf $ailog $CARGO_HOME/bin/
AILOG_DEBUG_ALL=1 $ailog stream start my-blog
}

View File

@@ -28,8 +28,31 @@ pub struct JetstreamConfig {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CollectionConfig {
pub comment: String,
pub user: String,
pub base: String, // Base collection name like "ai.syui.log"
}
impl CollectionConfig {
// Collection name builders
pub fn comment(&self) -> String {
self.base.clone()
}
pub fn user(&self) -> String {
format!("{}.user", self.base)
}
#[allow(dead_code)]
pub fn chat(&self) -> String {
format!("{}.chat", self.base)
}
pub fn chat_lang(&self) -> String {
format!("{}.chat.lang", self.base)
}
pub fn chat_comment(&self) -> String {
format!("{}.chat.comment", self.base)
}
}
impl Default for AuthConfig {
@@ -47,8 +70,7 @@ impl Default for AuthConfig {
collections: vec!["ai.syui.log".to_string()],
},
collections: CollectionConfig {
comment: "ai.syui.log".to_string(),
user: "ai.syui.log.user".to_string(),
base: "ai.syui.log".to_string(),
},
}
}
@@ -64,7 +86,125 @@ fn get_config_path() -> Result<PathBuf> {
Ok(config_dir.join("config.json"))
}
#[allow(dead_code)]
pub async fn init() -> Result<()> {
init_with_pds(None).await
}
pub async fn init_with_options(
pds_override: Option<String>,
handle_override: Option<String>,
use_password: bool,
access_jwt_override: Option<String>,
refresh_jwt_override: Option<String>
) -> Result<()> {
println!("{}", "🔐 Initializing ATProto authentication...".cyan());
let config_path = get_config_path()?;
if config_path.exists() {
println!("{}", "⚠️ Configuration already exists. Use 'ailog auth logout' to reset.".yellow());
return Ok(());
}
// Validate options
if let (Some(_), Some(_)) = (&access_jwt_override, &refresh_jwt_override) {
if use_password {
println!("{}", "⚠️ Cannot use both --password and JWT tokens. Choose one method.".yellow());
return Ok(());
}
} else if access_jwt_override.is_some() || refresh_jwt_override.is_some() {
println!("{}", "❌ Both --access-jwt and --refresh-jwt must be provided together.".red());
return Ok(());
}
println!("{}", "📋 Please provide your ATProto credentials:".cyan());
// Get handle
let handle = if let Some(h) = handle_override {
h
} else {
print!("Handle (e.g., your.handle.bsky.social): ");
std::io::Write::flush(&mut std::io::stdout())?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
input.trim().to_string()
};
// Determine PDS URL
let pds_url = if let Some(override_pds) = pds_override {
if override_pds.starts_with("http") {
override_pds
} else {
format!("https://{}", override_pds)
}
} else {
if handle.ends_with(".syu.is") {
"https://syu.is".to_string()
} else {
"https://bsky.social".to_string()
}
};
println!("{}", format!("🌐 Using PDS: {}", pds_url).cyan());
// Get credentials
let (access_jwt, refresh_jwt) = if let (Some(access), Some(refresh)) = (access_jwt_override, refresh_jwt_override) {
println!("{}", "🔑 Using provided JWT tokens".cyan());
(access, refresh)
} else if use_password {
println!("{}", "🔒 Using password authentication".cyan());
authenticate_with_password(&handle, &pds_url).await?
} else {
// Interactive JWT input (legacy behavior)
print!("Access JWT: ");
std::io::Write::flush(&mut std::io::stdout())?;
let mut access_jwt = String::new();
std::io::stdin().read_line(&mut access_jwt)?;
let access_jwt = access_jwt.trim().to_string();
print!("Refresh JWT: ");
std::io::Write::flush(&mut std::io::stdout())?;
let mut refresh_jwt = String::new();
std::io::stdin().read_line(&mut refresh_jwt)?;
let refresh_jwt = refresh_jwt.trim().to_string();
(access_jwt, refresh_jwt)
};
// Resolve DID from handle
println!("{}", "🔍 Resolving DID from handle...".cyan());
let did = resolve_did_with_pds(&handle, &pds_url).await?;
// Create config
let config = AuthConfig {
admin: AdminConfig {
did: did.clone(),
handle: handle.clone(),
access_jwt,
refresh_jwt,
pds: pds_url,
},
jetstream: JetstreamConfig {
url: "wss://jetstream2.us-east.bsky.network/subscribe".to_string(),
collections: vec!["ai.syui.log".to_string()],
},
collections: generate_collection_config(),
};
// Save config
let config_json = serde_json::to_string_pretty(&config)?;
fs::write(&config_path, config_json)?;
println!("{}", "✅ Authentication configured successfully!".green());
println!("📁 Config saved to: {}", config_path.display());
println!("👤 Authenticated as: {} ({})", handle, did);
Ok(())
}
#[allow(dead_code)]
pub async fn init_with_pds(pds_override: Option<String>) -> Result<()> {
println!("{}", "🔐 Initializing ATProto authentication...".cyan());
let config_path = get_config_path()?;
@@ -95,9 +235,28 @@ pub async fn init() -> Result<()> {
std::io::stdin().read_line(&mut refresh_jwt)?;
let refresh_jwt = refresh_jwt.trim().to_string();
// Determine PDS URL
let pds_url = if let Some(override_pds) = pds_override {
// Use provided PDS override
if override_pds.starts_with("http") {
override_pds
} else {
format!("https://{}", override_pds)
}
} else {
// Auto-detect from handle suffix
if handle.ends_with(".syu.is") {
"https://syu.is".to_string()
} else {
"https://bsky.social".to_string()
}
};
println!("{}", format!("🌐 Using PDS: {}", pds_url).cyan());
// Resolve DID from handle
println!("{}", "🔍 Resolving DID from handle...".cyan());
let did = resolve_did(&handle).await?;
let did = resolve_did_with_pds(&handle, &pds_url).await?;
// Create config
let config = AuthConfig {
@@ -106,11 +265,7 @@ pub async fn init() -> Result<()> {
handle: handle.clone(),
access_jwt,
refresh_jwt,
pds: if handle.ends_with(".syu.is") {
"https://syu.is".to_string()
} else {
"https://bsky.social".to_string()
},
pds: pds_url,
},
jetstream: JetstreamConfig {
url: "wss://jetstream2.us-east.bsky.network/subscribe".to_string(),
@@ -130,10 +285,19 @@ pub async fn init() -> Result<()> {
Ok(())
}
#[allow(dead_code)]
async fn resolve_did(handle: &str) -> Result<String> {
let client = reqwest::Client::new();
let url = format!("https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor={}",
urlencoding::encode(handle));
// Use appropriate API based on handle domain
let api_base = if handle.ends_with(".syu.is") {
"https://bsky.syu.is"
} else {
"https://public.api.bsky.app"
};
let url = format!("{}/xrpc/app.bsky.actor.getProfile?actor={}",
api_base, urlencoding::encode(handle));
let response = client.get(&url).send().await?;
@@ -148,6 +312,93 @@ async fn resolve_did(handle: &str) -> Result<String> {
Ok(did.to_string())
}
async fn resolve_did_with_pds(handle: &str, pds_url: &str) -> Result<String> {
let client = reqwest::Client::new();
// Try to use the PDS API first
let api_base = if pds_url.contains("syu.is") {
"https://bsky.syu.is"
} else if pds_url.contains("bsky.social") {
"https://public.api.bsky.app"
} else {
// For custom PDS, try to construct API URL
pds_url
};
let url = format!("{}/xrpc/app.bsky.actor.getProfile?actor={}",
api_base, urlencoding::encode(handle));
let response = client.get(&url).send().await?;
if !response.status().is_success() {
return Err(anyhow::anyhow!("Failed to resolve handle using PDS {}: {}", pds_url, response.status()));
}
let profile: serde_json::Value = response.json().await?;
let did = profile["did"].as_str()
.ok_or_else(|| anyhow::anyhow!("DID not found in profile response"))?;
Ok(did.to_string())
}
async fn authenticate_with_password(handle: &str, pds_url: &str) -> Result<(String, String)> {
use std::io::{self, Write};
// Get password securely
print!("Password: ");
io::stdout().flush()?;
let password = rpassword::read_password()
.context("Failed to read password")?;
if password.is_empty() {
return Err(anyhow::anyhow!("Password cannot be empty"));
}
println!("{}", "🔐 Authenticating with ATProto server...".cyan());
let client = reqwest::Client::new();
let auth_url = format!("{}/xrpc/com.atproto.server.createSession", pds_url);
let auth_request = serde_json::json!({
"identifier": handle,
"password": password
});
let response = client
.post(&auth_url)
.header("Content-Type", "application/json")
.json(&auth_request)
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let error_text = response.text().await.unwrap_or_default();
if status.as_u16() == 401 {
return Err(anyhow::anyhow!("Authentication failed: Invalid handle or password"));
} else if status.as_u16() == 400 {
return Err(anyhow::anyhow!("Authentication failed: Bad request (check handle format)"));
} else {
return Err(anyhow::anyhow!("Authentication failed: {} - {}", status, error_text));
}
}
let auth_response: serde_json::Value = response.json().await?;
let access_jwt = auth_response["accessJwt"].as_str()
.ok_or_else(|| anyhow::anyhow!("No access JWT in response"))?
.to_string();
let refresh_jwt = auth_response["refreshJwt"].as_str()
.ok_or_else(|| anyhow::anyhow!("No refresh JWT in response"))?
.to_string();
println!("{}", "✅ Password authentication successful".green());
Ok((access_jwt, refresh_jwt))
}
pub async fn status() -> Result<()> {
let config_path = get_config_path()?;
@@ -170,9 +421,17 @@ pub async fn status() -> Result<()> {
// Test API access
println!("\n{}", "🧪 Testing API access...".cyan());
match test_api_access(&config).await {
match test_api_access_with_auth(&config).await {
Ok(_) => println!("{}", "✅ API access successful".green()),
Err(e) => println!("{}", format!("❌ API access failed: {}", e).red()),
Err(e) => {
println!("{}", format!("❌ Authenticated API access failed: {}", e).red());
// Fallback to public API test
println!("{}", "🔄 Trying public API access...".cyan());
match test_api_access(&config).await {
Ok(_) => println!("{}", "✅ Public API access successful".green()),
Err(e2) => println!("{}", format!("❌ Public API access also failed: {}", e2).red()),
}
}
}
Ok(())
@@ -180,8 +439,16 @@ pub async fn status() -> Result<()> {
async fn test_api_access(config: &AuthConfig) -> Result<()> {
let client = reqwest::Client::new();
let url = format!("https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor={}",
urlencoding::encode(&config.admin.handle));
// Use appropriate API based on handle domain
let api_base = if config.admin.handle.ends_with(".syu.is") {
"https://bsky.syu.is"
} else {
"https://public.api.bsky.app"
};
let url = format!("{}/xrpc/app.bsky.actor.getProfile?actor={}",
api_base, urlencoding::encode(&config.admin.handle));
let response = client.get(&url).send().await?;
@@ -220,11 +487,50 @@ pub fn load_config() -> Result<AuthConfig> {
}
let config_json = fs::read_to_string(&config_path)?;
let mut config: AuthConfig = serde_json::from_str(&config_json)?;
// Update collection configuration
// Try to load as new format first, then migrate if needed
match serde_json::from_str::<AuthConfig>(&config_json) {
Ok(mut config) => {
// Update collection configuration
update_config_collections(&mut config);
Ok(config)
}
Err(e) => {
println!("{}", format!("Parse error: {}, attempting migration...", e).yellow());
// Try to migrate from old format
migrate_config_if_needed(&config_path, &config_json)
}
}
}
fn migrate_config_if_needed(config_path: &std::path::Path, config_json: &str) -> Result<AuthConfig> {
// Try to parse as old format and migrate to new simple format
let mut old_config: serde_json::Value = serde_json::from_str(config_json)?;
// Migrate old collections structure to new base-only structure
if let Some(collections) = old_config.get_mut("collections") {
// Extract base collection name from comment field or use default
let base_collection = collections.get("comment")
.and_then(|v| v.as_str())
.unwrap_or("ai.syui.log")
.to_string();
// Replace entire collections structure with new format
old_config["collections"] = serde_json::json!({
"base": base_collection
});
}
// Save migrated config
let migrated_config_json = serde_json::to_string_pretty(&old_config)?;
fs::write(config_path, migrated_config_json)?;
// Parse as new format
let mut config: AuthConfig = serde_json::from_value(old_config)?;
update_config_collections(&mut config);
println!("{}", "✅ Configuration migrated to new simplified format".green());
Ok(config)
}
@@ -259,7 +565,7 @@ async fn test_api_access_with_auth(config: &AuthConfig) -> Result<()> {
let url = format!("{}/xrpc/com.atproto.repo.listRecords?repo={}&collection={}&limit=1",
config.admin.pds,
urlencoding::encode(&config.admin.did),
urlencoding::encode(&config.collections.comment));
urlencoding::encode(&config.collections.comment()));
let response = client
.get(&url)
@@ -311,23 +617,14 @@ fn save_config(config: &AuthConfig) -> Result<()> {
Ok(())
}
// Generate collection names from admin DID or environment
// Generate collection config from environment
fn generate_collection_config() -> CollectionConfig {
// Check environment variables first
if let (Ok(comment), Ok(user)) = (
std::env::var("AILOG_COLLECTION_COMMENT"),
std::env::var("AILOG_COLLECTION_USER")
) {
return CollectionConfig {
comment,
user,
};
}
// Use VITE_OAUTH_COLLECTION for unified configuration
let base = std::env::var("VITE_OAUTH_COLLECTION")
.unwrap_or_else(|_| "ai.syui.log".to_string());
// Default collections
CollectionConfig {
comment: "ai.syui.log".to_string(),
user: "ai.syui.log.user".to_string(),
base,
}
}
@@ -335,5 +632,5 @@ fn generate_collection_config() -> CollectionConfig {
pub fn update_config_collections(config: &mut AuthConfig) {
config.collections = generate_collection_config();
// Also update jetstream collections to monitor the comment collection
config.jetstream.collections = vec![config.collections.comment.clone()];
config.jetstream.collections = vec![config.collections.comment()];
}

View File

@@ -1,6 +1,7 @@
use anyhow::Result;
use colored::Colorize;
use std::path::PathBuf;
use std::fs;
use crate::generator::Generator;
use crate::config::Config;
@@ -10,6 +11,12 @@ pub async fn execute(path: PathBuf) -> Result<()> {
// Load configuration
let config = Config::load(&path)?;
// Generate OAuth .env.production if oauth directory exists
let oauth_dir = path.join("oauth");
if oauth_dir.exists() {
generate_oauth_env(&path, &config)?;
}
// Create generator
let generator = Generator::new(path, config)?;
@@ -18,5 +25,104 @@ pub async fn execute(path: PathBuf) -> Result<()> {
println!("{}", "Build completed successfully!".green().bold());
Ok(())
}
fn generate_oauth_env(path: &PathBuf, config: &Config) -> Result<()> {
let oauth_dir = path.join("oauth");
let env_file = oauth_dir.join(".env.production");
// Extract configuration values
let base_url = &config.site.base_url;
let oauth_json = config.oauth.as_ref()
.and_then(|o| o.json.as_ref())
.map(|s| s.as_str())
.unwrap_or("client-metadata.json");
let oauth_redirect = config.oauth.as_ref()
.and_then(|o| o.redirect.as_ref())
.map(|s| s.as_str())
.unwrap_or("oauth/callback");
let admin_handle = config.oauth.as_ref()
.and_then(|o| o.admin.as_ref())
.map(|s| s.as_str())
.unwrap_or("ai.syui.ai");
let ai_handle = config.ai.as_ref()
.and_then(|a| a.handle.as_ref())
.map(|s| s.as_str())
.unwrap_or("ai.syui.ai");
let collection = config.oauth.as_ref()
.and_then(|o| o.collection.as_ref())
.map(|s| s.as_str())
.unwrap_or("ai.syui.log");
let pds = config.oauth.as_ref()
.and_then(|o| o.pds.as_ref())
.map(|s| s.as_str())
.unwrap_or("syu.is");
let handle_list = config.oauth.as_ref()
.and_then(|o| o.handle_list.as_ref())
.map(|list| format!("{:?}", list))
.unwrap_or_else(|| "[\"syui.syui.ai\",\"yui.syui.ai\",\"ai.syui.ai\"]".to_string());
// AI configuration
let ai_enabled = config.ai.as_ref().map(|a| a.enabled).unwrap_or(true);
let ai_ask_ai = config.ai.as_ref().and_then(|a| a.ask_ai).unwrap_or(true);
let ai_provider = config.ai.as_ref()
.and_then(|a| a.provider.as_ref())
.map(|s| s.as_str())
.unwrap_or("ollama");
let ai_model = config.ai.as_ref()
.and_then(|a| a.model.as_ref())
.map(|s| s.as_str())
.unwrap_or("gemma3:4b");
let ai_host = config.ai.as_ref()
.and_then(|a| a.host.as_ref())
.map(|s| s.as_str())
.unwrap_or("https://ollama.syui.ai");
let ai_system_prompt = config.ai.as_ref()
.and_then(|a| a.system_prompt.as_ref())
.map(|s| s.as_str())
.unwrap_or("あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。");
let env_content = format!(
r#"# Production environment variables
VITE_APP_HOST={}
VITE_OAUTH_CLIENT_ID={}/{}
VITE_OAUTH_REDIRECT_URI={}/{}
# Handle-based Configuration (DIDs resolved at runtime)
VITE_ATPROTO_PDS={}
VITE_ADMIN_HANDLE={}
VITE_AI_HANDLE={}
VITE_OAUTH_COLLECTION={}
VITE_ATPROTO_WEB_URL=https://bsky.app
VITE_ATPROTO_HANDLE_LIST={}
# AI Configuration
VITE_AI_ENABLED={}
VITE_AI_ASK_AI={}
VITE_AI_PROVIDER={}
VITE_AI_MODEL={}
VITE_AI_HOST={}
VITE_AI_SYSTEM_PROMPT="{}"
"#,
base_url,
base_url, oauth_json,
base_url, oauth_redirect,
pds,
admin_handle,
ai_handle,
collection,
handle_list,
ai_enabled,
ai_ask_ai,
ai_provider,
ai_model,
ai_host,
ai_system_prompt
);
fs::write(&env_file, env_content)?;
println!(" {} oauth/.env.production", "Generated".cyan());
Ok(())
}

View File

@@ -37,9 +37,23 @@ highlight_code = true
minify = false
[ai]
enabled = false
enabled = true
auto_translate = false
comment_moderation = false
ask_ai = true
provider = "ollama"
model = "gemma3:4b"
host = "https://ollama.syui.ai"
system_prompt = "あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"
handle = "ai.syui.ai"
[oauth]
json = "client-metadata.json"
redirect = "oauth/callback"
admin = "ai.syui.ai"
collection = "ai.syui.log"
pds = "syu.is"
handle_list = ["syui.syui.ai", "yui.syui.ai", "ai.syui.ai", "syui.syu.is", "ai.syu.is"]
"#;
fs::write(path.join("config.toml"), config_content)?;

View File

@@ -3,6 +3,8 @@ use std::path::{Path, PathBuf};
use std::fs;
use std::process::Command;
use toml::Value;
use serde_json;
use reqwest;
pub async fn build(project_dir: PathBuf) -> Result<()> {
println!("Building OAuth app for project: {}", project_dir.display());
@@ -41,83 +43,102 @@ pub async fn build(project_dir: PathBuf) -> Result<()> {
.and_then(|v| v.as_str())
.unwrap_or("oauth/callback");
let admin_did = oauth_config.get("admin")
// Get admin handle instead of DID
let admin_handle = oauth_config.get("admin")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("No admin DID found in [oauth] section"))?;
.ok_or_else(|| anyhow::anyhow!("No admin handle found in [oauth] section"))?;
let collection_comment = oauth_config.get("collection_comment")
let collection_base = oauth_config.get("collection")
.and_then(|v| v.as_str())
.unwrap_or("ai.syui.log");
let collection_user = oauth_config.get("collection_user")
.and_then(|v| v.as_str())
.unwrap_or("ai.syui.log.user");
// Get handle list for authentication restriction
let handle_list = oauth_config.get("handle_list")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str()).collect::<Vec<&str>>())
.unwrap_or_else(|| vec![]);
let collection_chat = oauth_config.get("collection_chat")
// Extract AI configuration from ai config if available
let ai_config = config.get("ai").and_then(|v| v.as_table());
// Get AI handle from config
let ai_handle = ai_config
.and_then(|ai_table| ai_table.get("ai_handle"))
.and_then(|v| v.as_str())
.unwrap_or("ai.syui.log.chat");
// Extract AI config if present
let ai_config = config.get("ai")
.and_then(|v| v.as_table());
.unwrap_or("yui.syui.ai");
let ai_enabled = ai_config
.and_then(|ai| ai.get("enabled"))
.and_then(|ai_table| ai_table.get("enabled"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
.unwrap_or(true);
let ai_ask_ai = ai_config
.and_then(|ai| ai.get("ask_ai"))
.and_then(|ai_table| ai_table.get("ask_ai"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
.unwrap_or(true);
let ai_provider = ai_config
.and_then(|ai| ai.get("provider"))
.and_then(|ai_table| ai_table.get("provider"))
.and_then(|v| v.as_str())
.unwrap_or("ollama");
let ai_model = ai_config
.and_then(|ai| ai.get("model"))
.and_then(|ai_table| ai_table.get("model"))
.and_then(|v| v.as_str())
.unwrap_or("gemma2:2b");
.unwrap_or("gemma3:4b");
let ai_host = ai_config
.and_then(|ai| ai.get("host"))
.and_then(|ai_table| ai_table.get("host"))
.and_then(|v| v.as_str())
.unwrap_or("https://ollama.syui.ai");
let ai_system_prompt = ai_config
.and_then(|ai| ai.get("system_prompt"))
.and_then(|ai_table| ai_table.get("system_prompt"))
.and_then(|v| v.as_str())
.unwrap_or("you are a helpful ai assistant");
.unwrap_or("あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。");
// Determine network configuration based on PDS
let pds = oauth_config.get("pds")
.and_then(|v| v.as_str())
.unwrap_or("bsky.social");
let ai_did = ai_config
.and_then(|ai| ai.get("ai_did"))
.and_then(|v| v.as_str())
.unwrap_or("did:plc:4hqjfn7m6n5hno3doamuhgef");
let (bsky_api, _atproto_api, web_url) = match pds {
"syu.is" => (
"https://bsky.syu.is",
"https://syu.is",
"https://web.syu.is"
),
"bsky.social" | "bsky.app" => (
"https://public.api.bsky.app",
"https://bsky.social",
"https://bsky.app"
),
_ => (
"https://public.api.bsky.app",
"https://bsky.social",
"https://bsky.app"
)
};
// Extract bsky_api from oauth config
let bsky_api = oauth_config.get("bsky_api")
.and_then(|v| v.as_str())
.unwrap_or("https://public.api.bsky.app");
// Resolve handles to DIDs using appropriate API
println!("🔍 Resolving admin handle: {}", admin_handle);
let admin_did = resolve_handle_to_did(admin_handle, &bsky_api).await
.with_context(|| format!("Failed to resolve admin handle: {}", admin_handle))?;
println!("🔍 Resolving AI handle: {}", ai_handle);
let ai_did = resolve_handle_to_did(ai_handle, &bsky_api).await
.with_context(|| format!("Failed to resolve AI handle: {}", ai_handle))?;
println!("✅ Admin DID: {}", admin_did);
println!("✅ AI DID: {}", ai_did);
// 4. Create .env.production content
// 4. Create .env.production content with handle-based configuration
let env_content = format!(
r#"# Production environment variables
VITE_APP_HOST={}
VITE_OAUTH_CLIENT_ID={}/{}
VITE_OAUTH_REDIRECT_URI={}/{}
VITE_ADMIN_DID={}
# Collection names for OAuth app
VITE_COLLECTION_COMMENT={}
VITE_COLLECTION_USER={}
VITE_COLLECTION_CHAT={}
# Collection names for ailog (backward compatibility)
AILOG_COLLECTION_COMMENT={}
AILOG_COLLECTION_USER={}
AILOG_COLLECTION_CHAT={}
# Handle-based Configuration (DIDs resolved at runtime)
VITE_ATPROTO_PDS={}
VITE_ADMIN_HANDLE={}
VITE_AI_HANDLE={}
VITE_OAUTH_COLLECTION={}
VITE_ATPROTO_WEB_URL={}
VITE_ATPROTO_HANDLE_LIST={}
# AI Configuration
VITE_AI_ENABLED={}
@@ -126,29 +147,28 @@ VITE_AI_PROVIDER={}
VITE_AI_MODEL={}
VITE_AI_HOST={}
VITE_AI_SYSTEM_PROMPT="{}"
VITE_AI_DID={}
# API Configuration
VITE_BSKY_PUBLIC_API={}
# DIDs (resolved from handles - for backward compatibility)
#VITE_ADMIN_DID={}
#VITE_AI_DID={}
"#,
base_url,
base_url, client_id_path,
base_url, redirect_path,
admin_did,
collection_comment,
collection_user,
collection_chat,
collection_comment,
collection_user,
collection_chat,
pds,
admin_handle,
ai_handle,
collection_base,
web_url,
format!("[{}]", handle_list.iter().map(|h| format!("\"{}\"", h)).collect::<Vec<_>>().join(",")),
ai_enabled,
ai_ask_ai,
ai_provider,
ai_model,
ai_host,
ai_system_prompt,
ai_did,
bsky_api
admin_did,
ai_did
);
// 5. Find oauth directory (relative to current working directory)
@@ -259,4 +279,60 @@ fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
}
Ok(())
}
// Handle-to-DID resolution with proper PDS detection
async fn resolve_handle_to_did(handle: &str, _api_base: &str) -> Result<String> {
let client = reqwest::Client::new();
// First, try to resolve handle to DID using multiple endpoints
let bsky_endpoints = ["https://public.api.bsky.app", "https://bsky.syu.is"];
let mut resolved_did = None;
for endpoint in &bsky_endpoints {
let url = format!("{}/xrpc/app.bsky.actor.getProfile?actor={}",
endpoint, urlencoding::encode(handle));
if let Ok(response) = client.get(&url).send().await {
if response.status().is_success() {
if let Ok(profile) = response.json::<serde_json::Value>().await {
if let Some(did) = profile["did"].as_str() {
resolved_did = Some(did.to_string());
break;
}
}
}
}
}
let did = resolved_did
.ok_or_else(|| anyhow::anyhow!("Failed to resolve handle '{}' from any endpoint", handle))?;
// Now verify the DID and get actual PDS using com.atproto.repo.describeRepo
let pds_endpoints = ["https://bsky.social", "https://syu.is"];
for pds in &pds_endpoints {
let describe_url = format!("{}/xrpc/com.atproto.repo.describeRepo?repo={}",
pds, urlencoding::encode(&did));
if let Ok(response) = client.get(&describe_url).send().await {
if response.status().is_success() {
if let Ok(data) = response.json::<serde_json::Value>().await {
if let Some(services) = data["didDoc"]["service"].as_array() {
if services.iter().any(|s|
s["id"] == "#atproto_pds" || s["type"] == "AtprotoPersonalDataServer"
) {
// DID is valid and has PDS service
println!("✅ Verified DID {} has PDS via {}", did, pds);
return Ok(did);
}
}
}
}
}
}
// If PDS verification fails, still return the DID but warn
println!("⚠️ Could not verify PDS for DID {}, but proceeding...", did);
Ok(did)
}

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@ pub struct Config {
pub site: SiteConfig,
pub build: BuildConfig,
pub ai: Option<AiConfig>,
pub oauth: Option<OAuthConfig>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
@@ -37,10 +38,22 @@ pub struct AiConfig {
pub model: Option<String>,
pub host: Option<String>,
pub system_prompt: Option<String>,
pub handle: Option<String>,
pub ai_did: Option<String>,
pub api_key: Option<String>,
pub gpt_endpoint: Option<String>,
pub atproto_config: Option<AtprotoConfig>,
pub num_predict: Option<i32>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct OAuthConfig {
pub json: Option<String>,
pub redirect: Option<String>,
pub admin: Option<String>,
pub collection: Option<String>,
pub pds: Option<String>,
pub handle_list: Option<Vec<String>>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
@@ -159,11 +172,14 @@ impl Default for Config {
model: Some("gemma3:4b".to_string()),
host: None,
system_prompt: Some("You are a helpful AI assistant trained on this blog's content.".to_string()),
handle: None,
ai_did: None,
api_key: None,
gpt_endpoint: None,
atproto_config: None,
num_predict: None,
}),
oauth: None,
}
}
}

View File

@@ -71,6 +71,9 @@ impl Generator {
// Generate index page
self.generate_index(&posts).await?;
// Generate JSON index for API access
self.generate_json_index(&posts).await?;
// Generate post pages
for post in &posts {
self.generate_post_page(post).await?;
@@ -446,6 +449,63 @@ impl Generator {
Ok(())
}
async fn generate_json_index(&self, posts: &[Post]) -> Result<()> {
let index_data: Vec<serde_json::Value> = posts.iter().map(|post| {
// Parse date for proper formatting
let parsed_date = chrono::NaiveDate::parse_from_str(&post.date, "%Y-%m-%d")
.unwrap_or_else(|_| chrono::Utc::now().naive_utc().date());
// Format to Hugo-style date format (Mon Jan 2, 2006)
let formatted_date = parsed_date.format("%a %b %-d, %Y").to_string();
// Create UTC datetime for utc_time field
let utc_datetime = parsed_date.and_hms_opt(0, 0, 0)
.unwrap_or_else(|| chrono::Utc::now().naive_utc());
let utc_time = format!("{}Z", utc_datetime.format("%Y-%m-%dT%H:%M:%S"));
// Extract plain text content from HTML
let contents = self.extract_plain_text(&post.content);
serde_json::json!({
"title": post.title,
"tags": post.tags,
"description": self.extract_excerpt(&post.content),
"categories": [],
"contents": contents,
"href": format!("{}{}", self.config.site.base_url.trim_end_matches('/'), post.url),
"utc_time": utc_time,
"formated_time": formatted_date
})
}).collect();
// Write JSON index to public directory
let output_path = self.base_path.join("public/index.json");
let json_content = serde_json::to_string_pretty(&index_data)?;
fs::write(output_path, json_content)?;
println!("{} JSON index with {} posts", "Generated".cyan(), posts.len());
Ok(())
}
fn extract_plain_text(&self, html_content: &str) -> String {
// Remove HTML tags and extract plain text
let mut text = String::new();
let mut in_tag = false;
for ch in html_content.chars() {
match ch {
'<' => in_tag = true,
'>' => in_tag = false,
_ if !in_tag => text.push(ch),
_ => {}
}
}
// Clean up whitespace
text.split_whitespace().collect::<Vec<_>>().join(" ")
}
}
#[derive(Debug, Clone, serde::Serialize)]
@@ -479,4 +539,19 @@ pub struct Translation {
pub title: String,
pub content: String,
pub url: String,
}
}
#[derive(Debug, Clone, serde::Serialize)]
#[allow(dead_code)]
struct BlogPost {
title: String,
url: String,
date: String,
}
#[derive(Debug, Clone, serde::Serialize)]
#[allow(dead_code)]
struct BlogIndex {
posts: Vec<BlogPost>,
}

14
src/lib.rs Normal file
View File

@@ -0,0 +1,14 @@
// Export modules for testing
pub mod ai;
pub mod analyzer;
pub mod atproto;
pub mod commands;
pub mod config;
pub mod doc_generator;
pub mod generator;
pub mod markdown;
pub mod mcp;
pub mod oauth;
// pub mod ollama_proxy; // Temporarily disabled - uses actix-web instead of axum
pub mod template;
pub mod translator;

View File

@@ -102,7 +102,23 @@ enum Commands {
#[derive(Subcommand)]
enum AuthCommands {
/// Initialize OAuth authentication
Init,
Init {
/// Specify PDS server (e.g., syu.is, bsky.social)
#[arg(long)]
pds: Option<String>,
/// Handle/username for authentication
#[arg(long)]
handle: Option<String>,
/// Use password authentication instead of JWT
#[arg(long)]
password: bool,
/// Access JWT token (alternative to password auth)
#[arg(long)]
access_jwt: Option<String>,
/// Refresh JWT token (required with access-jwt)
#[arg(long)]
refresh_jwt: Option<String>,
},
/// Show current authentication status
Status,
/// Logout and clear credentials
@@ -118,6 +134,17 @@ enum StreamCommands {
/// Run as daemon
#[arg(short, long)]
daemon: bool,
/// Enable AI content generation
#[arg(long)]
ai_generate: bool,
},
/// Initialize user list for admin account
Init {
/// Path to the blog project directory
project_dir: Option<PathBuf>,
/// Handles to add to initial user list (comma-separated)
#[arg(long)]
handles: Option<String>,
},
/// Stop monitoring
Stop,
@@ -180,8 +207,8 @@ async fn main() -> Result<()> {
}
Commands::Auth { command } => {
match command {
AuthCommands::Init => {
commands::auth::init().await?;
AuthCommands::Init { pds, handle, password, access_jwt, refresh_jwt } => {
commands::auth::init_with_options(pds, handle, password, access_jwt, refresh_jwt).await?;
}
AuthCommands::Status => {
commands::auth::status().await?;
@@ -193,8 +220,11 @@ async fn main() -> Result<()> {
}
Commands::Stream { command } => {
match command {
StreamCommands::Start { project_dir, daemon } => {
commands::stream::start(project_dir, daemon).await?;
StreamCommands::Start { project_dir, daemon, ai_generate } => {
commands::stream::start(project_dir, daemon, ai_generate).await?;
}
StreamCommands::Init { project_dir, handles } => {
commands::stream::init_user_list(project_dir, handles).await?;
}
StreamCommands::Stop => {
commands::stream::stop().await?;

View File

@@ -2,6 +2,7 @@ use anyhow::Result;
use regex::Regex;
use super::MarkdownSection;
#[derive(Clone)]
pub struct MarkdownParser {
_code_block_regex: Regex,
header_regex: Regex,

View File

@@ -42,9 +42,9 @@ pub enum MarkdownSection {
pub trait Translator {
#[allow(dead_code)]
async fn translate(&self, content: &str, config: &TranslationConfig) -> Result<String>;
async fn translate_markdown(&self, content: &str, config: &TranslationConfig) -> Result<String>;
async fn translate_sections(&self, sections: Vec<MarkdownSection>, config: &TranslationConfig) -> Result<Vec<MarkdownSection>>;
fn translate(&self, content: &str, config: &TranslationConfig) -> impl std::future::Future<Output = Result<String>> + Send;
fn translate_markdown(&self, content: &str, config: &TranslationConfig) -> impl std::future::Future<Output = Result<String>> + Send;
fn translate_sections(&self, sections: Vec<MarkdownSection>, config: &TranslationConfig) -> impl std::future::Future<Output = Result<Vec<MarkdownSection>>> + Send;
}
#[allow(dead_code)]
@@ -67,6 +67,7 @@ pub struct TranslationMetrics {
pub sections_preserved: usize,
}
#[derive(Clone)]
pub struct LanguageMapping {
pub mappings: HashMap<String, LanguageInfo>,
}

View File

@@ -5,6 +5,7 @@ use std::time::Instant;
use super::*;
use crate::translator::markdown_parser::MarkdownParser;
#[derive(Clone)]
pub struct OllamaTranslator {
client: Client,
language_mapping: LanguageMapping,
@@ -129,86 +130,103 @@ Translation:"#,
}
impl Translator for OllamaTranslator {
async fn translate(&self, content: &str, config: &TranslationConfig) -> Result<String> {
let prompt = self.build_translation_prompt(content, config)?;
self.call_ollama(&prompt, config).await
fn translate(&self, content: &str, config: &TranslationConfig) -> impl std::future::Future<Output = Result<String>> + Send {
async move {
let prompt = self.build_translation_prompt(content, config)?;
self.call_ollama(&prompt, config).await
}
}
async fn translate_markdown(&self, content: &str, config: &TranslationConfig) -> Result<String> {
println!("🔄 Parsing markdown content...");
let sections = self.parser.parse_markdown(content)?;
println!("📝 Found {} sections to process", sections.len());
let translated_sections = self.translate_sections(sections, config).await?;
println!("✅ Rebuilding markdown from translated sections...");
let result = self.parser.rebuild_markdown(translated_sections);
Ok(result)
}
async fn translate_sections(&self, sections: Vec<MarkdownSection>, config: &TranslationConfig) -> Result<Vec<MarkdownSection>> {
let mut translated_sections = Vec::new();
let start_time = Instant::now();
for (index, section) in sections.into_iter().enumerate() {
println!(" 🔤 Processing section {}", index + 1);
fn translate_markdown(&self, content: &str, config: &TranslationConfig) -> impl std::future::Future<Output = Result<String>> + Send {
async move {
println!("🔄 Parsing markdown content...");
let sections = self.parser.parse_markdown(content)?;
let translated_section = match &section {
MarkdownSection::Code(_content, _lang) => {
if config.preserve_code {
println!(" ⏭️ Preserving code block");
section // Preserve code blocks
} else {
section // Still preserve for now
}
}
MarkdownSection::Link(text, url) => {
if config.preserve_links {
println!(" ⏭️ Preserving link");
section // Preserve links
} else {
// Translate link text only
let prompt = self.build_section_translation_prompt(&MarkdownSection::Text(text.clone()), config)?;
let translated_text = self.call_ollama(&prompt, config).await?;
MarkdownSection::Link(translated_text.trim().to_string(), url.clone())
}
}
MarkdownSection::Image(_alt, _url) => {
println!(" 🖼️ Preserving image");
section // Preserve images
}
MarkdownSection::Table(content) => {
println!(" 📊 Translating table content");
let prompt = self.build_section_translation_prompt(&MarkdownSection::Text(content.clone()), config)?;
let translated_content = self.call_ollama(&prompt, config).await?;
MarkdownSection::Table(translated_content.trim().to_string())
}
_ => {
// Translate text sections
println!(" 🔤 Translating text");
let prompt = self.build_section_translation_prompt(&section, config)?;
let translated_text = self.call_ollama(&prompt, config).await?;
match section {
MarkdownSection::Text(_) => MarkdownSection::Text(translated_text.trim().to_string()),
MarkdownSection::Header(_, level) => MarkdownSection::Header(translated_text.trim().to_string(), level),
MarkdownSection::Quote(_) => MarkdownSection::Quote(translated_text.trim().to_string()),
MarkdownSection::List(_) => MarkdownSection::List(translated_text.trim().to_string()),
_ => section,
}
}
println!("📝 Found {} sections to process", sections.len());
let translated_sections = self.translate_sections(sections, config).await?;
println!("✅ Rebuilding markdown from translated sections...");
let result = self.parser.rebuild_markdown(translated_sections);
Ok(result)
}
}
fn translate_sections(&self, sections: Vec<MarkdownSection>, config: &TranslationConfig) -> impl std::future::Future<Output = Result<Vec<MarkdownSection>>> + Send {
let config = config.clone();
let client = self.client.clone();
let parser = self.parser.clone();
let language_mapping = self.language_mapping.clone();
async move {
let translator = OllamaTranslator {
client,
language_mapping,
parser,
};
translated_sections.push(translated_section);
// Add small delay to avoid overwhelming Ollama
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
let mut translated_sections = Vec::new();
let start_time = Instant::now();
for (index, section) in sections.into_iter().enumerate() {
println!(" 🔤 Processing section {}", index + 1);
let translated_section = match &section {
MarkdownSection::Code(_content, _lang) => {
if config.preserve_code {
println!(" ⏭️ Preserving code block");
section // Preserve code blocks
} else {
section // Still preserve for now
}
}
MarkdownSection::Link(text, url) => {
if config.preserve_links {
println!(" ⏭️ Preserving link");
section // Preserve links
} else {
// Translate link text only
let prompt = translator.build_section_translation_prompt(&MarkdownSection::Text(text.clone()), &config)?;
let translated_text = translator.call_ollama(&prompt, &config).await?;
MarkdownSection::Link(translated_text.trim().to_string(), url.clone())
}
}
MarkdownSection::Image(_alt, _url) => {
println!(" 🖼️ Preserving image");
section // Preserve images
}
MarkdownSection::Table(content) => {
println!(" 📊 Translating table content");
let prompt = translator.build_section_translation_prompt(&MarkdownSection::Text(content.clone()), &config)?;
let translated_content = translator.call_ollama(&prompt, &config).await?;
MarkdownSection::Table(translated_content.trim().to_string())
}
_ => {
// Translate text sections
println!(" 🔤 Translating text");
let prompt = translator.build_section_translation_prompt(&section, &config)?;
let translated_text = translator.call_ollama(&prompt, &config).await?;
match section {
MarkdownSection::Text(_) => MarkdownSection::Text(translated_text.trim().to_string()),
MarkdownSection::Header(_, level) => MarkdownSection::Header(translated_text.trim().to_string(), level),
MarkdownSection::Quote(_) => MarkdownSection::Quote(translated_text.trim().to_string()),
MarkdownSection::List(_) => MarkdownSection::List(translated_text.trim().to_string()),
_ => section,
}
}
};
translated_sections.push(translated_section);
// Add small delay to avoid overwhelming Ollama
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
}
let elapsed = start_time.elapsed();
println!("⏱️ Translation completed in {:.2}s", elapsed.as_secs_f64());
Ok(translated_sections)
}
let elapsed = start_time.elapsed();
println!("⏱️ Translation completed in {:.2}s", elapsed.as_secs_f64());
Ok(translated_sections)
}
}

View File

@@ -6,9 +6,9 @@ Wants=network.target
[Service]
Type=simple
User=syui
Group=syui
WorkingDirectory=/home/syui/git/log
ExecStart=/home/syui/.cargo/bin/ailog stream start my-blog
ExecStart=/home/syui/.cargo/bin/ailog stream start my-blog --ai-generate
ExecStop=/home/syui/.cargo/bin/ailog stream stop
Restart=always
RestartSec=5
StandardOutput=journal
@@ -19,4 +19,4 @@ Environment=RUST_LOG=info
Environment=AILOG_DEBUG_ALL=1
[Install]
WantedBy=multi-user.target
WantedBy=multi-user.target

View File

@@ -1,103 +0,0 @@
# API Documentation
## Public Functions
{{#each api.public_functions}}
### `{{this.name}}`
{{#if this.docs}}
{{this.docs}}
{{/if}}
**Visibility:** `{{this.visibility}}`
{{#if this.is_async}}**Async:** Yes{{/if}}
{{#if this.parameters}}
**Parameters:**
{{#each this.parameters}}
- `{{this.name}}`: `{{this.param_type}}`{{#if this.is_mutable}} (mutable){{/if}}
{{/each}}
{{/if}}
{{#if this.return_type}}
**Returns:** `{{this.return_type}}`
{{/if}}
---
{{/each}}
## Public Structs
{{#each api.public_structs}}
### `{{this.name}}`
{{#if this.docs}}
{{this.docs}}
{{/if}}
**Visibility:** `{{this.visibility}}`
{{#if this.fields}}
**Fields:**
{{#each this.fields}}
- `{{this.name}}`: `{{this.field_type}}` ({{this.visibility}})
{{#if this.docs}} - {{this.docs}}{{/if}}
{{/each}}
{{/if}}
---
{{/each}}
## Public Enums
{{#each api.public_enums}}
### `{{this.name}}`
{{#if this.docs}}
{{this.docs}}
{{/if}}
**Visibility:** `{{this.visibility}}`
{{#if this.variants}}
**Variants:**
{{#each this.variants}}
- `{{this.name}}`
{{#if this.docs}} - {{this.docs}}{{/if}}
{{#if this.fields}}
**Fields:**
{{#each this.fields}}
- `{{this.name}}`: `{{this.field_type}}`
{{/each}}
{{/if}}
{{/each}}
{{/if}}
---
{{/each}}
## Public Traits
{{#each api.public_traits}}
### `{{this.name}}`
{{#if this.docs}}
{{this.docs}}
{{/if}}
**Visibility:** `{{this.visibility}}`
{{#if this.methods}}
**Methods:**
{{#each this.methods}}
- `{{this.name}}({{#each this.parameters}}{{this.name}}: {{this.param_type}}{{#unless @last}}, {{/unless}}{{/each}}){{#if this.return_type}} -> {{this.return_type}}{{/if}}`
{{#if this.docs}} - {{this.docs}}{{/if}}
{{/each}}
{{/if}}
---
{{/each}}

View File

@@ -1,19 +0,0 @@
# Changelog
## Recent Changes
{{#each commits}}
### {{this.date}}
**{{this.hash}}** by {{this.author}}
{{this.message}}
---
{{/each}}
## Summary
- **Total Commits:** {{commits.length}}
- **Contributors:** {{#unique commits "author"}}{{this.author}}{{#unless @last}}, {{/unless}}{{/unique}}

View File

@@ -1,76 +0,0 @@
# {{project.name}}
{{#if project.description}}
{{project.description}}
{{/if}}
## Overview
This project contains {{project.modules.length}} modules with a total of {{project.metrics.total_lines}} lines of code.
## Installation
```bash
cargo install {{project.name}}
```
## Usage
```bash
{{project.name}} --help
```
## Dependencies
{{#each project.dependencies}}
- `{{@key}}`: {{this}}
{{/each}}
## Project Structure
```
{{#each project.structure.directories}}
{{this.name}}/
{{/each}}
```
## API Documentation
{{#each project.modules}}
### {{this.name}}
{{#if this.docs}}
{{this.docs}}
{{/if}}
{{#if this.functions}}
**Functions:** {{this.functions.length}}
{{/if}}
{{#if this.structs}}
**Structs:** {{this.structs.length}}
{{/if}}
{{/each}}
## Metrics
- **Lines of Code:** {{project.metrics.total_lines}}
- **Total Files:** {{project.metrics.total_files}}
- **Test Files:** {{project.metrics.test_files}}
- **Dependencies:** {{project.metrics.dependency_count}}
- **Complexity Score:** {{project.metrics.complexity_score}}
## License
{{#if project.license}}
{{project.license}}
{{else}}
MIT
{{/if}}
## Authors
{{#each project.authors}}
- {{this}}
{{/each}}

View File

@@ -1,39 +0,0 @@
# Project Structure
## Directory Overview
```
{{#each structure.directories}}
{{this.name}}/
{{#each this.subdirectories}}
├── {{this}}/
{{/each}}
{{#if this.file_count}}
└── ({{this.file_count}} files)
{{/if}}
{{/each}}
```
## File Distribution
{{#each structure.files}}
- **{{this.name}}** ({{this.language}}) - {{this.lines_of_code}} lines{{#if this.is_test}} [TEST]{{/if}}
{{/each}}
## Statistics
- **Total Directories:** {{structure.directories.length}}
- **Total Files:** {{structure.files.length}}
- **Languages Used:**
{{#group structure.files by="language"}}
- {{@key}}: {{this.length}} files
{{/group}}
{{#if structure.dependency_graph}}
## Dependencies
{{#each structure.dependency_graph}}
- **{{@key}}** depends on: {{#each this}}{{this}}{{#unless @last}}, {{/unless}}{{/each}}
{{/each}}
{{/if}}

View File

@@ -1,19 +0,0 @@
{
"version": 2,
"builds": [
{
"src": "my-blog/public/**",
"use": "@vercel/static"
}
],
"routes": [
{
"src": "/api/ask",
"dest": "/api/ask.js"
},
{
"src": "/(.*)",
"dest": "/my-blog/public/$1"
}
]
}

View File

@@ -1,93 +0,0 @@
// Cloudflare Worker for secure Ollama proxy
export default {
async fetch(request, env, ctx) {
// CORS preflight
if (request.method === 'OPTIONS') {
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': 'https://log.syui.ai',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, X-User-Token',
'Access-Control-Max-Age': '86400',
}
});
}
// Verify origin
const origin = request.headers.get('Origin');
const referer = request.headers.get('Referer');
// 許可されたオリジンのみ
const allowedOrigins = [
'https://log.syui.ai',
'https://log.pages.dev' // Cloudflare Pages preview
];
if (!origin || !allowedOrigins.some(allowed => origin.startsWith(allowed))) {
return new Response('Forbidden', { status: 403 });
}
// ユーザー認証トークン検証(オプション)
const userToken = request.headers.get('X-User-Token');
if (env.REQUIRE_AUTH && !userToken) {
return new Response('Unauthorized', { status: 401 });
}
// リクエストボディを取得
const body = await request.json();
// プロンプトサイズ制限
if (body.prompt && body.prompt.length > 1000) {
return new Response(JSON.stringify({
error: 'Prompt too long. Maximum 1000 characters.'
}), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
// レート制限CF Workers KV使用
if (env.RATE_LIMITER) {
const clientIP = request.headers.get('CF-Connecting-IP');
const rateLimitKey = `rate:${clientIP}`;
const currentCount = await env.RATE_LIMITER.get(rateLimitKey) || 0;
if (currentCount >= 20) {
return new Response(JSON.stringify({
error: 'Rate limit exceeded. Try again later.'
}), {
status: 429,
headers: { 'Content-Type': 'application/json' }
});
}
// カウント増加1時間TTL
await env.RATE_LIMITER.put(rateLimitKey, currentCount + 1, {
expirationTtl: 3600
});
}
// Ollamaへプロキシ
const ollamaResponse = await fetch(env.OLLAMA_API_URL || 'https://ollama.syui.ai/api/generate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// 内部認証ヘッダー(必要に応じて)
'X-Internal-Token': env.OLLAMA_INTERNAL_TOKEN || ''
},
body: JSON.stringify(body)
});
// レスポンスを返す
const responseData = await ollamaResponse.text();
return new Response(responseData, {
status: ollamaResponse.status,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': origin,
'Cache-Control': 'no-store'
}
});
}
};

View File

@@ -1,20 +0,0 @@
name = "ollama-proxy"
main = "ollama-proxy.js"
compatibility_date = "2024-01-01"
# 環境変数
[vars]
REQUIRE_AUTH = false
# 本番環境
[env.production.vars]
OLLAMA_API_URL = "https://ollama.syui.ai/api/generate"
REQUIRE_AUTH = true
# KVネームスペースレート制限用
[[kv_namespaces]]
binding = "RATE_LIMITER"
id = "your-kv-namespace-id"
# シークレットwrangler secret putで設定
# OLLAMA_INTERNAL_TOKEN = "your-internal-token"

View File

@@ -1,31 +0,0 @@
name = "ailog"
compatibility_date = "2024-01-01"
[env.production]
name = "ailog"
[build]
command = "cargo build --release && ./target/release/ailog build my-blog"
publish = "my-blog/public"
[[redirects]]
from = "/api/ask"
to = "https://ai-gpt-mcp.your-domain.com/ask"
status = 200
[[headers]]
for = "/*"
[headers.values]
X-Frame-Options = "DENY"
X-Content-Type-Options = "nosniff"
Referrer-Policy = "strict-origin-when-cross-origin"
[[headers]]
for = "/css/*"
[headers.values]
Cache-Control = "public, max-age=31536000, immutable"
[[headers]]
for = "*.js"
[headers.values]
Cache-Control = "public, max-age=31536000, immutable"