34 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
70 changed files with 3438 additions and 1601 deletions

View File

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

8
.gitignore vendored
View File

@@ -5,10 +5,16 @@
*.swo *.swo
*~ *~
.DS_Store .DS_Store
cloudflare-config.yml
my-blog/public/ my-blog/public/
dist dist
node_modules node_modules
package-lock.json package-lock.json
my-blog/static/assets/comment-atproto-* my-blog/static/assets/comment-atproto-*
bin/ailog 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] [package]
name = "ailog" name = "ailog"
version = "0.1.5" version = "0.2.1"
edition = "2021" edition = "2021"
authors = ["syui"] authors = ["syui"]
description = "A static blog generator with AI features" description = "A static blog generator with AI features"
@@ -10,6 +10,10 @@ license = "MIT"
name = "ailog" name = "ailog"
path = "src/main.rs" path = "src/main.rs"
[lib]
name = "ailog"
path = "src/lib.rs"
[dependencies] [dependencies]
clap = { version = "4.5", features = ["derive"] } clap = { version = "4.5", features = ["derive"] }
pulldown-cmark = "0.11" 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 } tokio-tungstenite = { version = "0.21", features = ["rustls-tls-webpki-roots", "connect"], default-features = false }
futures-util = "0.3" futures-util = "0.3"
tungstenite = { version = "0.21", features = ["rustls-tls-webpki-roots"], default-features = false } tungstenite = { version = "0.21", features = ["rustls-tls-webpki-roots"], default-features = false }
rpassword = "7.3"
[dev-dependencies] [dev-dependencies]
tempfile = "3.14" 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"]

View File

@@ -1,128 +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: Setup ailog binary
shell: bash
run: |
# Check if pre-built binary exists
if [ -f "./bin/ailog-linux-x86_64" ]; then
echo "Using pre-built binary from repository"
chmod +x ./bin/ailog-linux-x86_64
CURRENT_VERSION=$(./bin/ailog-linux-x86_64 --version 2>/dev/null || echo "unknown")
echo "Binary version: $CURRENT_VERSION"
else
echo "No pre-built binary found, trying to build from source..."
if command -v cargo >/dev/null 2>&1; then
cargo build --release
mkdir -p ./bin
cp ./target/release/ailog ./bin/ailog-linux-x86_64
echo "Built from source: $(./bin/ailog-linux-x86_64 --version 2>/dev/null)"
else
echo "Error: No binary found and cargo not available"
exit 1
fi
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)
# Change to blog directory and run build
# Note: ailog build only takes a path argument, not options
if [ -d "my-blog" ]; then
cd my-blog
../bin/ailog-linux-x86_64 build
else
# If no my-blog directory, use current directory
./bin/ailog-linux-x86_64 build .
fi
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

View File

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

Binary file not shown.

View File

@@ -1,42 +0,0 @@
#!/bin/zsh
#[collection] [pds] [did] [token]
set -e
f=~/.config/syui/ai/bot/token.json
default_collection="ai.syui.log.chat"
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`
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`
collection=${1:-$default_collection}
pds=${2:-$default_pds}
did=${3:-$default_did}
token=${4:-$default_token}
delete_record() {
local rkey=$1
local req="com.atproto.repo.deleteRecord"
local url="https://$pds/xrpc/$req"
local json="{\"collection\":\"$collection\", \"rkey\":\"$rkey\", \"repo\":\"$did\"}"
curl -sL -X POST \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $token" \
-d "$json" \
"$url"
if [ $? -eq 0 ]; then
echo " ✓ Deleted: $rkey"
else
echo " ✗ Failed: $rkey"
fi
}
rkeys=($(curl -sL "https://$default_pds/xrpc/com.atproto.repo.listRecords?repo=$did&collection=$collection&limit=100"|jq -r ".records[]?.uri"|cut -d '/' -f 5))
for rkey in "${rkeys[@]}"; do
echo $rkey
delete_record $rkey
done

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の探求 - **存在子理論**: この世界で最も小さいもの(存在子/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

@@ -17,13 +17,15 @@ comment_moderation = false
ask_ai = true ask_ai = true
provider = "ollama" provider = "ollama"
model = "gemma3:4b" model = "gemma3:4b"
host = "https://ollama.syui.ai" host = "http://localhost:11434"
system_prompt = "あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。" system_prompt = "あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"
ai_did = "did:plc:4hqjfn7m6n5hno3doamuhgef" handle = "ai.syui.ai"
#num_predict = 200
[oauth] [oauth]
json = "client-metadata.json" json = "client-metadata.json"
redirect = "oauth/callback" redirect = "oauth/callback"
admin = "did:plc:uqzpqmrjnptsxezjx4xuh2mn" admin = "ai.syui.ai"
collection = "ai.syui.log" collection = "ai.syui.log"
bsky_api = "https://public.api.bsky.app" pds = "syu.is"
handle_list = ["syui.syui.ai", "ai.syui.ai", "ai.ai"]

View File

@@ -57,24 +57,28 @@ $ npm run build
$ npm run preview $ npm run preview
``` ```
```sh ```sh:ouath/.env.production
# Production environment variables # Production environment variables
VITE_APP_HOST=https://example.com VITE_APP_HOST=https://syui.ai
VITE_OAUTH_CLIENT_ID=https://example.com/client-metadata.json VITE_OAUTH_CLIENT_ID=https://syui.ai/client-metadata.json
VITE_OAUTH_REDIRECT_URI=https://example.com/oauth/callback VITE_OAUTH_REDIRECT_URI=https://syui.ai/oauth/callback
VITE_ADMIN_DID=did:plc:uqzpqmrjnptsxezjx4xuh2mn VITE_ADMIN_DID=did:plc:uqzpqmrjnptsxezjx4xuh2mn
# Collection names for OAuth app # Base collection (all others are derived via getCollectionNames)
VITE_COLLECTION_COMMENT=ai.syui.log VITE_OAUTH_COLLECTION=ai.syui.log
VITE_COLLECTION_USER=ai.syui.log.user
VITE_COLLECTION_CHAT=ai.syui.log.chat
# Collection names for ailog (backward compatibility) # AI Configuration
AILOG_COLLECTION_COMMENT=ai.syui.log VITE_AI_ENABLED=true
AILOG_COLLECTION_USER=ai.syui.log.user 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 # API Configuration
VITE_BSKY_PUBLIC_API=https://public.api.bsky.app 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`が生成されます。 これは`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 $ cloudflared tunnel route dns ${uuid} example.com
``` ```
以下の2つのcollection recordを生成します。ユーザーには`ai.syui.log`が生成され、ここにコメントが記録されます。それを取得して表示しています。`ai.syui.log.user`は管理者である`VITE_ADMIN_DID`用です。
```sh ```sh
VITE_COLLECTION_COMMENT=ai.syui.log $ ailog auth init
VITE_COLLECTION_USER=ai.syui.log.user
```
```sh
$ ailog auth login
$ ailog stream server $ ailog stream server
``` ```
@@ -135,8 +132,9 @@ $ ailog stream server
`ask-AI`の仕組みは割愛します。後に変更される可能性が高いと思います。 `ask-AI`の仕組みは割愛します。後に変更される可能性が高いと思います。
local llm, mcp, atproto組み合わせです。 `llm`, `mcp`, `atproto`などの組み合わせです。
現在、`/index.json`を監視して、更新があれば、翻訳などを行い自動ポストする機能があります。
## code syntax ## code syntax

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

@@ -1,6 +1,6 @@
{ {
"client_id": "https://syui.ai/client-metadata.json", "client_id": "https://syui.ai/client-metadata.json",
"client_name": "ai.card", "client_name": "ai.log",
"client_uri": "https://syui.ai", "client_uri": "https://syui.ai",
"logo_uri": "https://syui.ai/favicon.ico", "logo_uri": "https://syui.ai/favicon.ico",
"tos_uri": "https://syui.ai/terms", "tos_uri": "https://syui.ai/terms",
@@ -21,4 +21,4 @@
"subject_type": "public", "subject_type": "public",
"application_type": "web", "application_type": "web",
"dpop_bound_access_tokens": true "dpop_bound_access_tokens": true
} }

View File

@@ -248,7 +248,7 @@ a.view-markdown:any-link {
} }
.post-title a { .post-title a {
color: #1f2328; color: var(--theme-color);
text-decoration: none; text-decoration: none;
font-size: 18px; font-size: 18px;
font-weight: 600; font-weight: 600;
@@ -822,6 +822,13 @@ article.article-content {
} }
.comment-section { .comment-section {
max-width: 100% !important;
padding: 0px !important;
margin: 0px !important;
}
.comment-container {
max-width: 100% !important;
padding: 0px !important; padding: 0px !important;
margin: 0px !important; margin: 0px !important;
} }

View File

@@ -1,3 +0,0 @@
<!-- OAuth Comment System - Load globally for session management -->
<script type="module" crossorigin src="/assets/comment-atproto-G86WWmu8.js"></script>
<link rel="stylesheet" crossorigin href="/assets/comment-atproto-FS0uZjXB.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

@@ -253,6 +253,20 @@ function setupAskAIEventListeners() {
handleAIResponse(event.detail); 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 // Keyboard shortcuts
document.addEventListener('keydown', function(e) { document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') { if (e.key === 'Escape') {
@@ -262,8 +276,8 @@ function setupAskAIEventListeners() {
} }
} }
// Enter key to send message // Enter key to send message (only when not composing Japanese input)
if (e.key === 'Enter' && e.target.id === 'aiQuestion' && !e.shiftKey) { if (e.key === 'Enter' && e.target.id === 'aiQuestion' && !e.shiftKey && !isComposing) {
e.preventDefault(); e.preventDefault();
askQuestion(); askQuestion();
} }

View File

@@ -82,7 +82,7 @@
<footer class="main-footer"> <footer class="main-footer">
<div class="footer-social"> <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/ai" target="_blank"><span class="icon-ai"></span></a>
<a href="https://git.syui.ai/syui" target="_blank"><span class="icon-git"></span></a> <a href="https://git.syui.ai/syui" target="_blank"><span class="icon-git"></span></a>
</div> </div>

View File

@@ -20,19 +20,6 @@
<a href="{{ post.url }}">{{ post.title }}</a> <a href="{{ post.url }}">{{ post.title }}</a>
</h3> </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> </div>
</article> </article>
{% endfor %} {% endfor %}

View File

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

View File

@@ -2,11 +2,14 @@
VITE_APP_HOST=https://syui.ai VITE_APP_HOST=https://syui.ai
VITE_OAUTH_CLIENT_ID=https://syui.ai/client-metadata.json VITE_OAUTH_CLIENT_ID=https://syui.ai/client-metadata.json
VITE_OAUTH_REDIRECT_URI=https://syui.ai/oauth/callback VITE_OAUTH_REDIRECT_URI=https://syui.ai/oauth/callback
VITE_ADMIN_DID=did:plc:uqzpqmrjnptsxezjx4xuh2mn
# Base collection for OAuth app and ailog (all others are derived) # 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_OAUTH_COLLECTION=ai.syui.log
# [user, chat, chat.lang, chat.comment] VITE_ATPROTO_WEB_URL=https://bsky.app
VITE_ATPROTO_HANDLE_LIST=["syui.syui.ai","ai.syui.ai","ai.ai"]
# AI Configuration # AI Configuration
VITE_AI_ENABLED=true VITE_AI_ENABLED=true
@@ -15,7 +18,4 @@ VITE_AI_PROVIDER=ollama
VITE_AI_MODEL=gemma3:4b VITE_AI_MODEL=gemma3:4b
VITE_AI_HOST=https://ollama.syui.ai VITE_AI_HOST=https://ollama.syui.ai
VITE_AI_SYSTEM_PROMPT="あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。" VITE_AI_SYSTEM_PROMPT="あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"
VITE_AI_DID=did:plc:4hqjfn7m6n5hno3doamuhgef
# API Configuration
VITE_BSKY_PUBLIC_API=https://public.api.bsky.app

View File

@@ -1,13 +1,15 @@
{ {
"name": "aicard", "name": "aicard",
"version": "0.1.0", "version": "0.1.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite --mode development", "dev": "vite --mode development",
"build": "vite build --mode production", "build": "vite build --mode production",
"build:dev": "vite build --mode development", "build:dev": "vite build --mode development",
"build:local": "VITE_APP_HOST=http://localhost:4173 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": { "dependencies": {
"@atproto/api": "^0.15.12", "@atproto/api": "^0.15.12",
@@ -26,6 +28,9 @@
"@types/react-dom": "^18.2.18", "@types/react-dom": "^18.2.18",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
"typescript": "^5.3.3", "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_id": "https://syui.ai/client-metadata.json",
"client_name": "ai.card", "client_name": "ai.log",
"client_uri": "https://log.syui.ai", "client_uri": "https://syui.ai",
"logo_uri": "https://log.syui.ai/favicon.ico", "logo_uri": "https://syui.ai/favicon.ico",
"tos_uri": "https://log.syui.ai/terms", "tos_uri": "https://syui.ai/terms",
"policy_uri": "https://log.syui.ai/privacy", "policy_uri": "https://syui.ai/privacy",
"redirect_uris": [ "redirect_uris": [
"https://log.syui.ai/oauth/callback", "https://syui.ai/oauth/callback",
"https://log.syui.ai/" "https://syui.ai/"
], ],
"response_types": [ "response_types": [
"code" "code"
@@ -21,4 +21,4 @@
"subject_type": "public", "subject_type": "public",
"application_type": "web", "application_type": "web",
"dpop_bound_access_tokens": true "dpop_bound_access_tokens": true
} }

View File

@@ -168,7 +168,14 @@
} }
@media (max-width: 1000px) { @media (max-width: 1000px) {
* {
max-width: 100% !important;
box-sizing: border-box !important;
}
.app .app-main { .app .app-main {
max-width: 100% !important;
margin: 0 !important;
padding: 0px !important; padding: 0px !important;
} }
@@ -186,14 +193,15 @@
} }
.comment-section { .comment-section {
padding: 0px !important; padding: 30px 0 !important;
margin: 0px !important; margin: 0px !important;
} }
.comment-content { .comment-content {
padding: 10px !important; padding: 10px !important;
word-wrap: break-word !important; word-wrap: break-word !important;
overflow-wrap: break-word !important; overflow-wrap: break-word !important;
white-space: pre-wrap !important;
} }
.comment-header { .comment-header {
@@ -208,6 +216,7 @@
/* Ensure full width on mobile */ /* Ensure full width on mobile */
.app { .app {
max-width: 100vw !important; max-width: 100vw !important;
overflow-x: hidden !important;
} }
/* Fix button overflow */ /* Fix button overflow */
@@ -323,6 +332,15 @@
/* padding: 20px; - removed to avoid double padding */ /* padding: 20px; - removed to avoid double padding */
} }
@media (max-width: 768px) {
.comment-section {
max-width: 100%;
margin: 0;
padding: 0;
}
}
.auth-section { .auth-section {
background: #f8f9fa; background: #f8f9fa;
border: 1px solid #e9ecef; border: 1px solid #e9ecef;
@@ -332,6 +350,38 @@
text-align: center; 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 { .atproto-button {
background: var(--theme-color); background: var(--theme-color);
color: var(--white); color: var(--white);
@@ -365,6 +415,30 @@
text-align: center; 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 { .auth-hint {
color: #6c757d; color: #6c757d;
font-size: 14px; font-size: 14px;
@@ -497,9 +571,8 @@
} }
.comments-list { .comments-list {
border: 1px solid #ddd;
border-radius: 8px; border-radius: 8px;
padding: 20px; padding: 0px;
} }
.comments-header { .comments-header {
@@ -610,6 +683,8 @@
line-height: 1.5; line-height: 1.5;
color: #333; color: #333;
margin-bottom: 10px; margin-bottom: 10px;
white-space: pre-wrap;
word-wrap: break-word;
} }
.comment-meta { .comment-meta {
@@ -856,28 +931,6 @@
background: #f6f8fa; 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 { .chat-actions {
display: flex; display: flex;
@@ -928,4 +981,8 @@
padding: 40px 20px; padding: 40px 20px;
color: #656d76; color: #656d76;
font-style: italic; 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: { options: {
temperature: 0.9, temperature: 0.9,
top_p: 0.9, top_p: 0.9,
num_predict: 80, num_predict: 200,
repeat_penalty: 1.1, repeat_penalty: 1.1,
} }
}), }),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -160,7 +160,7 @@ export const Login: React.FC<LoginProps> = ({ onLogin, onClose, defaultHandle })
/> />
<small> <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> </a>
使 使

View File

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

View File

@@ -6,14 +6,9 @@ export const OAuthCallbackPage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { 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) => { 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 // Add a small delay to ensure state is properly updated
setTimeout(() => { setTimeout(() => {
@@ -22,7 +17,6 @@ export const OAuthCallbackPage: React.FC = () => {
}; };
const handleError = (error: string) => { const handleError = (error: string) => {
console.error('OAuth error, redirecting to home:', error);
// Add a small delay before redirect // Add a small delay before redirect
setTimeout(() => { setTimeout(() => {

View File

@@ -1,6 +1,12 @@
// Application configuration // Application configuration
export interface AppConfig { export interface AppConfig {
adminDid: string; adminDid: string;
adminHandle: string;
aiDid: string;
aiHandle: string;
aiDisplayName: string;
aiAvatar: string;
aiDescription: string;
collections: { collections: {
base: string; // Base collection like "ai.syui.log" base: string; // Base collection like "ai.syui.log"
}; };
@@ -11,18 +17,30 @@ export interface AppConfig {
aiProvider: string; aiProvider: string;
aiModel: string; aiModel: string;
aiHost: 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; bskyPublicApi: string;
atprotoApi: string;
} }
// Collection name builders (similar to Rust implementation) // Collection name builders (similar to Rust implementation)
export function getCollectionNames(base: string) { export function getCollectionNames(base: string) {
return { if (!base) {
// Fallback to default
base = 'ai.syui.log';
}
const collections = {
comment: base, comment: base,
user: `${base}.user`, user: `${base}.user`,
chat: `${base}.chat`, chat: `${base}.chat`,
chatLang: `${base}.chat.lang`, chatLang: `${base}.chat.lang`,
chatComment: `${base}.chat.comment`, chatComment: `${base}.chat.comment`,
}; };
return collections;
} }
// Generate collection names from host // Generate collection names from host
@@ -43,31 +61,50 @@ function generateBaseCollectionFromHost(host: string): string {
// Reverse the parts for collection naming // Reverse the parts for collection naming
// log.syui.ai -> ai.syui.log // log.syui.ai -> ai.syui.log
const reversedParts = parts.reverse(); const reversedParts = parts.reverse();
return reversedParts.join('.'); const result = reversedParts.join('.');
return result;
} catch (error) { } catch (error) {
console.warn('Failed to generate collection base from host:', host, error);
// Fallback to default // Fallback to default
return 'ai.syui.log'; return 'ai.syui.log';
} }
} }
// Extract rkey from current URL // Extract rkey from current URL
// /posts/xxx -> xxx // /posts/xxx -> xxx (remove .html if present)
function extractRkeyFromUrl(): string | undefined { function extractRkeyFromUrl(): string | undefined {
const pathname = window.location.pathname; const pathname = window.location.pathname;
const match = pathname.match(/\/posts\/([^/]+)\/?$/); const match = pathname.match(/\/posts\/([^/]+)\/?$/);
return match ? match[1] : undefined; if (match) {
// Remove .html extension if present
return match[1].replace(/\.html$/, '');
}
return undefined;
} }
// Get application configuration from environment variables // Get application configuration from environment variables
export function getAppConfig(): AppConfig { export function getAppConfig(): AppConfig {
const host = import.meta.env.VITE_APP_HOST || 'https://log.syui.ai'; 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 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 // Priority: Environment variables > Auto-generated from host
const autoGeneratedBase = generateBaseCollectionFromHost(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 = { const collections = {
base: import.meta.env.VITE_OAUTH_COLLECTION || autoGeneratedBase, base: baseCollection,
}; };
const rkey = extractRkeyFromUrl(); const rkey = extractRkeyFromUrl();
@@ -78,19 +115,29 @@ export function getAppConfig(): AppConfig {
const aiProvider = import.meta.env.VITE_AI_PROVIDER || 'ollama'; const aiProvider = import.meta.env.VITE_AI_PROVIDER || 'ollama';
const aiModel = import.meta.env.VITE_AI_MODEL || 'gemma2:2b'; const aiModel = import.meta.env.VITE_AI_MODEL || 'gemma2:2b';
const aiHost = import.meta.env.VITE_AI_HOST || 'https://ollama.syui.ai'; 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 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:', { // Parse allowed handles list
host, const allowedHandlesStr = import.meta.env.VITE_ATPROTO_HANDLE_LIST || '[]';
adminDid, let allowedHandles: string[] = [];
collections, try {
rkey: rkey || 'none (not on post page)', allowedHandles = JSON.parse(allowedHandlesStr);
ai: { enabled: aiEnabled, askAi: aiAskAi, provider: aiProvider, model: aiModel, host: aiHost }, } catch {
bskyPublicApi // If parsing fails, allow all handles (empty array means no restriction)
}); allowedHandles = [];
}
return { return {
adminDid, adminDid,
adminHandle,
aiDid,
aiHandle,
aiDisplayName,
aiAvatar,
aiDescription,
collections, collections,
host, host,
rkey, rkey,
@@ -99,7 +146,11 @@ export function getAppConfig(): AppConfig {
aiProvider, aiProvider,
aiModel, aiModel,
aiHost, 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 // Mount React app to all comment-atproto divs
const mountPoints = document.querySelectorAll('#comment-atproto'); const mountPoints = document.querySelectorAll('#comment-atproto');
console.log(`Found ${mountPoints.length} comment-atproto mount points`);
mountPoints.forEach((mountPoint, index) => { mountPoints.forEach((mountPoint, index) => {
console.log(`Mounting React app to comment-atproto #${index + 1}`);
ReactDOM.createRoot(mountPoint as HTMLElement).render( ReactDOM.createRoot(mountPoint as HTMLElement).render(
<React.StrictMode> <React.StrictMode>
<BrowserRouter> <BrowserRouter>

View File

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

View File

@@ -31,51 +31,48 @@ class AtprotoOAuthService {
private async _doInitialize(): Promise<void> { private async _doInitialize(): Promise<void> {
try { try {
console.log('=== INITIALIZING ATPROTO OAUTH CLIENT ===');
// Generate client ID based on current origin // Generate client ID based on current origin
const clientId = this.getClientId(); const clientId = this.getClientId();
console.log('Client ID:', clientId);
// Support multiple PDS hosts for OAuth // Support multiple PDS hosts for OAuth
console.log('[OAuth Debug] Initializing OAuth client with default settings');
this.oauthClient = await BrowserOAuthClient.load({ this.oauthClient = await BrowserOAuthClient.load({
clientId: clientId, clientId: clientId,
handleResolver: 'https://bsky.social', // Default resolver 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 // Try to restore existing session
const result = await this.oauthClient.init(); const result = await this.oauthClient.init();
if (result?.session) { 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 // 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 // Delete the old agent initialization code - we'll create it properly below
// Set the session after creating the agent // Set the session after creating the agent
// The session object from BrowserOAuthClient appears to be a special object // 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 // Try to iterate over the session object
if (result.session) { if (result.session) {
console.log('Session properties:');
for (const key in result.session) { for (const key in result.session) {
console.log(` ${key}:`, result.session[key]);
} }
// Check if session has methods // Check if session has methods
const methods = Object.getOwnPropertyNames(Object.getPrototypeOf(result.session)); 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 // BrowserOAuthClient might return a Session object that needs to be used with the agent
@@ -83,36 +80,36 @@ class AtprotoOAuthService {
if (result.session) { if (result.session) {
// Process the session to extract DID and handle // Process the session to extract DID and handle
const sessionData = await this.processSession(result.session); const sessionData = await this.processSession(result.session);
console.log('Session processed during initialization:', sessionData);
} }
} else { } else {
console.log('No existing session found');
} }
} catch (error) { } catch (error) {
console.error('Failed to initialize OAuth client:', error);
this.initializePromise = null; // Reset on error to allow retry this.initializePromise = null; // Reset on error to allow retry
throw error; throw error;
} }
} }
private async processSession(session: any): Promise<{ did: string; handle: string }> { private async processSession(session: any): Promise<{ did: string; handle: string }> {
console.log('Processing session:', session);
// Log full session structure // 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 // Check if agent has properties we can access
if (session.agent) { 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; const did = session.sub || session.did;
@@ -121,23 +118,24 @@ class AtprotoOAuthService {
// Create Agent directly with session (per official docs) // Create Agent directly with session (per official docs)
try { try {
this.agent = new Agent(session); 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 // 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) { if (this.agent.session) {
console.log('- agent.session.did:', this.agent.session.did); console.log('[OAuth Debug] Agent has session:', this.agent.session);
console.log('- agent.session.handle:', this.agent.session.handle);
} }
} catch (err) { } 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 // Fallback to dpopFetch method
this.agent = new Agent({ try {
service: session.server?.serviceEndpoint || 'https://bsky.social', this.agent = new Agent({
fetch: session.dpopFetch 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 // Store basic session info
@@ -145,7 +143,7 @@ class AtprotoOAuthService {
// If handle is missing, try multiple methods to resolve it // If handle is missing, try multiple methods to resolve it
if (!handle || handle === 'unknown') { if (!handle || handle === 'unknown') {
console.log('Handle not in session, attempting to resolve...');
// Method 1: Try using the agent to get profile // Method 1: Try using the agent to get profile
try { try {
@@ -154,11 +152,11 @@ class AtprotoOAuthService {
if (profile.data.handle) { if (profile.data.handle) {
handle = profile.data.handle; handle = profile.data.handle;
(this as any)._sessionInfo.handle = handle; (this as any)._sessionInfo.handle = handle;
console.log('Successfully resolved handle via getProfile:', handle);
return { did, handle }; return { did, handle };
} }
} catch (err) { } catch (err) {
console.error('getProfile failed:', err);
} }
// Method 2: Try using describeRepo // Method 2: Try using describeRepo
@@ -169,18 +167,20 @@ class AtprotoOAuthService {
if (repoDesc.data.handle) { if (repoDesc.data.handle) {
handle = repoDesc.data.handle; handle = repoDesc.data.handle;
(this as any)._sessionInfo.handle = handle; (this as any)._sessionInfo.handle = handle;
console.log('Got handle from describeRepo:', handle);
return { did, handle }; return { did, handle };
} }
} catch (err) { } catch (err) {
console.error('describeRepo failed:', err);
} }
// Method 3: Hardcoded fallback for known DIDs // Method 3: Fallback for admin DID
if (did === 'did:plc:uqzpqmrjnptsxezjx4xuh2mn') { const adminDid = import.meta.env.VITE_ADMIN_DID;
handle = 'syui.ai'; 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; (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 // Use environment variable if available
const envClientId = import.meta.env.VITE_OAUTH_CLIENT_ID; const envClientId = import.meta.env.VITE_OAUTH_CLIENT_ID;
if (envClientId) { if (envClientId) {
console.log('Using client ID from environment:', envClientId);
return envClientId; return envClientId;
} }
@@ -200,7 +200,7 @@ class AtprotoOAuthService {
// For localhost development, use undefined for loopback client // For localhost development, use undefined for loopback client
// The BrowserOAuthClient will handle this automatically // The BrowserOAuthClient will handle this automatically
if (origin.includes('localhost') || origin.includes('127.0.0.1')) { if (origin.includes('localhost') || origin.includes('127.0.0.1')) {
console.log('Using loopback client for localhost development');
return undefined as any; // Loopback client return undefined as any; // Loopback client
} }
@@ -208,34 +208,56 @@ class AtprotoOAuthService {
return `${origin}/client-metadata.json`; return `${origin}/client-metadata.json`;
} }
private detectPDSFromHandle(handle: string): string { private async detectPDSFromHandle(handle: string): Promise<string> {
console.log('Detecting PDS for handle:', handle); // Handle detection for OAuth PDS routing
// Supported PDS hosts and their corresponding handles // Check if handle ends with known PDS domains first
const pdsMapping = { const pdsMapping = {
'syu.is': 'https://syu.is', 'syu.is': 'https://syu.is',
'bsky.social': 'https://bsky.social', 'bsky.social': 'https://bsky.social',
}; };
// Check if handle ends with known PDS domains
for (const [domain, pdsUrl] of Object.entries(pdsMapping)) { for (const [domain, pdsUrl] of Object.entries(pdsMapping)) {
if (handle.endsWith(`.${domain}`)) { if (handle.endsWith(`.${domain}`)) {
console.log(`Handle ${handle} mapped to PDS: ${pdsUrl}`); // Using PDS for domain match
return pdsUrl; 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 // Default to bsky.social
console.log(`Handle ${handle} using default PDS: https://bsky.social`); // Using default bsky.social
return 'https://bsky.social'; return 'https://bsky.social';
} }
async initiateOAuthFlow(handle?: string): Promise<void> { async initiateOAuthFlow(handle?: string): Promise<void> {
try { try {
console.log('=== INITIATING OAUTH FLOW ===');
if (!this.oauthClient) { if (!this.oauthClient) {
console.log('OAuth client not initialized, initializing now...');
await this.initialize(); await this.initialize();
} }
@@ -251,75 +273,71 @@ class AtprotoOAuthService {
} }
} }
console.log('Starting OAuth flow for handle:', handle);
// Detect PDS based on handle // Detect PDS based on handle
const pdsUrl = this.detectPDSFromHandle(handle); const pdsUrl = await this.detectPDSFromHandle(handle);
console.log('Detected PDS for handle:', { handle, pdsUrl }); console.log('[OAuth Debug] Detected PDS for handle', handle, ':', pdsUrl);
// Re-initialize OAuth client with correct PDS if needed // Always re-initialize OAuth client with detected PDS
if (pdsUrl !== 'https://bsky.social') { console.log('[OAuth Debug] Re-initializing OAuth client');
console.log('Re-initializing OAuth client for custom PDS:', pdsUrl);
this.oauthClient = await BrowserOAuthClient.load({ // Clear existing client to force fresh initialization
clientId: this.getClientId(), this.oauthClient = null;
handleResolver: pdsUrl, 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 // Start OAuth authorization flow
console.log('Calling oauthClient.authorize with handle:', handle);
try { try {
// Starting OAuth authorization
// Use handle directly since PLC directory is now correctly configured
const authUrl = await this.oauthClient.authorize(handle, { const authUrl = await this.oauthClient.authorize(handle, {
scope: 'atproto transition:generic', 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 // Redirect to authorization server
console.log('About to redirect to:', authUrl.toString());
window.location.href = authUrl.toString(); window.location.href = authUrl.toString();
} catch (authorizeError) { } catch (authorizeError) {
console.error('oauthClient.authorize failed:', authorizeError); // Authorization failed
console.error('Error details:', {
name: authorizeError.name,
message: authorizeError.message,
stack: authorizeError.stack
});
throw authorizeError; throw authorizeError;
} }
} catch (error) { } catch (error) {
console.error('Failed to initiate OAuth flow:', error);
throw new Error(`OAuth認証の開始に失敗しました: ${error}`); throw new Error(`OAuth認証の開始に失敗しました: ${error}`);
} }
} }
async handleOAuthCallback(): Promise<{ did: string; handle: string } | null> { async handleOAuthCallback(): Promise<{ did: string; handle: string } | null> {
try { 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 // BrowserOAuthClient should automatically handle the callback
// We just need to initialize it and it will process the current URL // We just need to initialize it and it will process the current URL
if (!this.oauthClient) { if (!this.oauthClient) {
console.log('OAuth client not initialized, initializing now...');
await this.initialize(); await this.initialize();
} }
@@ -327,11 +345,11 @@ class AtprotoOAuthService {
throw new Error('Failed to initialize OAuth client'); 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 // Call init() again to process the callback URL
const result = await this.oauthClient.init(); const result = await this.oauthClient.init();
console.log('OAuth callback processing result:', result);
if (result?.session) { if (result?.session) {
// Process the session // Process the session
@@ -339,56 +357,54 @@ class AtprotoOAuthService {
} }
// If no session yet, wait a bit and try again // If no session yet, wait a bit and try again
console.log('No session found immediately, waiting...');
await new Promise(resolve => setTimeout(resolve, 1000)); await new Promise(resolve => setTimeout(resolve, 1000));
// Try to check session again // Try to check session again
const sessionCheck = await this.checkSession(); const sessionCheck = await this.checkSession();
if (sessionCheck) { if (sessionCheck) {
console.log('Session found after delay:', sessionCheck);
return sessionCheck; return sessionCheck;
} }
console.warn('OAuth callback completed but no session was created');
return null; return null;
} catch (error) { } 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}`); throw new Error(`OAuth認証の完了に失敗しました: ${error.message}`);
} }
} }
async checkSession(): Promise<{ did: string; handle: string } | null> { async checkSession(): Promise<{ did: string; handle: string } | null> {
try { try {
console.log('=== CHECK SESSION CALLED ==='); console.log('[OAuth Debug] Checking session...');
if (!this.oauthClient) { if (!this.oauthClient) {
console.log('No OAuth client, initializing...'); console.log('[OAuth Debug] No OAuth client, initializing...');
await this.initialize(); await this.initialize();
} }
if (!this.oauthClient) { if (!this.oauthClient) {
console.log('OAuth client initialization failed'); console.log('[OAuth Debug] Failed to initialize OAuth client');
return null; return null;
} }
console.log('Running oauthClient.init() to check session...');
const result = await this.oauthClient.init(); const result = await this.oauthClient.init();
console.log('oauthClient.init() result:', result); console.log('[OAuth Debug] OAuth init result:', !!result?.session);
if (result?.session) { if (result?.session) {
console.log('[OAuth Debug] Session found, processing...');
// Use the common session processing method // 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; return null;
} catch (error) { } catch (error) {
console.error('Session check failed:', error);
return null; return null;
} }
} }
@@ -398,13 +414,7 @@ class AtprotoOAuthService {
} }
getSession(): AtprotoSession | null { 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 // First check if we have an agent with session
if (this.agent?.session) { if (this.agent?.session) {
@@ -414,7 +424,7 @@ class AtprotoOAuthService {
accessJwt: this.agent.session.accessJwt || '', accessJwt: this.agent.session.accessJwt || '',
refreshJwt: this.agent.session.refreshJwt || '', refreshJwt: this.agent.session.refreshJwt || '',
}; };
console.log('Returning agent session:', session);
return session; return session;
} }
@@ -426,11 +436,11 @@ class AtprotoOAuthService {
accessJwt: 'dpop-protected', // Indicate that tokens are handled by dpopFetch accessJwt: 'dpop-protected', // Indicate that tokens are handled by dpopFetch
refreshJwt: 'dpop-protected', refreshJwt: 'dpop-protected',
}; };
console.log('Returning stored session info:', session);
return session; return session;
} }
console.log('No session available');
return null; return null;
} }
@@ -450,28 +460,28 @@ class AtprotoOAuthService {
async logout(): Promise<void> { async logout(): Promise<void> {
try { try {
console.log('=== LOGGING OUT ===');
// Clear Agent // Clear Agent
this.agent = null; this.agent = null;
console.log('Agent cleared');
// Clear BrowserOAuthClient session // Clear BrowserOAuthClient session
if (this.oauthClient) { if (this.oauthClient) {
console.log('Clearing OAuth client session...');
try { try {
// BrowserOAuthClient may have a revoke or signOut method // BrowserOAuthClient may have a revoke or signOut method
if (typeof (this.oauthClient as any).signOut === 'function') { if (typeof (this.oauthClient as any).signOut === 'function') {
await (this.oauthClient as any).signOut(); await (this.oauthClient as any).signOut();
console.log('OAuth client signed out');
} else if (typeof (this.oauthClient as any).revoke === 'function') { } else if (typeof (this.oauthClient as any).revoke === 'function') {
await (this.oauthClient as any).revoke(); await (this.oauthClient as any).revoke();
console.log('OAuth client revoked');
} else { } else {
console.log('No explicit signOut method found on OAuth client');
} }
} catch (oauthError) { } catch (oauthError) {
console.error('OAuth client logout error:', oauthError);
} }
// Reset the OAuth client to force re-initialization // Reset the OAuth client to force re-initialization
@@ -492,11 +502,11 @@ class AtprotoOAuthService {
} }
} }
keysToRemove.forEach(key => { keysToRemove.forEach(key => {
console.log('Removing localStorage key:', key);
localStorage.removeItem(key); localStorage.removeItem(key);
}); });
console.log('=== LOGOUT COMPLETED ===');
// Force page reload to ensure clean state // Force page reload to ensure clean state
setTimeout(() => { setTimeout(() => {
@@ -504,7 +514,7 @@ class AtprotoOAuthService {
}, 100); }, 100);
} catch (error) { } catch (error) {
console.error('Logout failed:', error);
} }
} }
@@ -519,8 +529,8 @@ class AtprotoOAuthService {
const did = sessionInfo.did; const did = sessionInfo.did;
try { try {
console.log('Saving cards to atproto collection...');
console.log('Using DID:', did);
// Ensure we have a fresh agent // Ensure we have a fresh agent
if (!this.agent) { if (!this.agent) {
@@ -550,13 +560,6 @@ class AtprotoOAuthService {
createdAt: createdAt createdAt: createdAt
}; };
console.log('PutRecord request:', {
repo: did,
collection: collection,
rkey: rkey,
record: record
});
// Use Agent's com.atproto.repo.putRecord method // Use Agent's com.atproto.repo.putRecord method
const response = await this.agent.com.atproto.repo.putRecord({ const response = await this.agent.com.atproto.repo.putRecord({
@@ -566,9 +569,9 @@ class AtprotoOAuthService {
record: record record: record
}); });
console.log('カードデータをai.card.boxに保存しました:', response);
} catch (error) { } catch (error) {
console.error('カードボックス保存エラー:', error);
throw error; throw error;
} }
} }
@@ -584,8 +587,8 @@ class AtprotoOAuthService {
const did = sessionInfo.did; const did = sessionInfo.did;
try { try {
console.log('Fetching cards from atproto collection...');
console.log('Using DID:', did);
// Ensure we have a fresh agent // Ensure we have a fresh agent
if (!this.agent) { if (!this.agent) {
@@ -598,7 +601,7 @@ class AtprotoOAuthService {
rkey: 'self' rkey: 'self'
}); });
console.log('Cards from box response:', response);
// Convert to expected format // Convert to expected format
const result = { const result = {
@@ -611,7 +614,7 @@ class AtprotoOAuthService {
return result; return result;
} catch (error) { } catch (error) {
console.error('カードボックス取得エラー:', error);
// If record doesn't exist, return empty // If record doesn't exist, return empty
if (error.toString().includes('RecordNotFound')) { if (error.toString().includes('RecordNotFound')) {
@@ -633,8 +636,8 @@ class AtprotoOAuthService {
const did = sessionInfo.did; const did = sessionInfo.did;
try { try {
console.log('Deleting card box collection...');
console.log('Using DID:', did);
// Ensure we have a fresh agent // Ensure we have a fresh agent
if (!this.agent) { if (!this.agent) {
@@ -647,33 +650,35 @@ class AtprotoOAuthService {
rkey: 'self' rkey: 'self'
}); });
console.log('Card box deleted successfully:', response);
} catch (error) { } catch (error) {
console.error('カードボックス削除エラー:', error);
throw error; throw error;
} }
} }
// 手動でトークンを設定(開発・デバッグ用) // 手動でトークンを設定(開発・デバッグ用)
setManualTokens(accessJwt: string, refreshJwt: string): void { 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 // 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 = { const session: AtprotoSession = {
did: 'did:plc:uqzpqmrjnptsxezjx4xuh2mn', did: adminDid,
handle: 'syui.ai', handle: new URL(appHost).hostname,
accessJwt: accessJwt, accessJwt: accessJwt,
refreshJwt: refreshJwt refreshJwt: refreshJwt
}; };
localStorage.setItem('atproto_session', JSON.stringify(session)); localStorage.setItem('atproto_session', JSON.stringify(session));
console.log('Manual tokens stored in localStorage for backward compatibility');
} }
// 後方互換性のための従来関数 // 後方互換性のための従来関数
saveSessionToStorage(session: AtprotoSession): void { saveSessionToStorage(session: AtprotoSession): void {
console.warn('saveSessionToStorage is deprecated with BrowserOAuthClient');
localStorage.setItem('atproto_session', JSON.stringify(session)); 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) { } catch (error) {
console.error('Failed to generate JWKS:', error);
return new Response(JSON.stringify({ error: 'Failed to generate JWKS' }), { return new Response(JSON.stringify({ error: 'Failed to generate JWKS' }), {
status: 500, status: 500,
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' }
@@ -62,7 +61,6 @@ export class OAuthEndpointHandler {
} }
} catch (e) { } catch (e) {
// If URL parsing fails, pass through to original fetch // If URL parsing fails, pass through to original fetch
console.debug('URL parsing failed, passing through:', e);
} }
// Pass through all other requests // Pass through all other requests
@@ -136,6 +134,5 @@ export function registerOAuthServiceWorker() {
const blob = new Blob([swCode], { type: 'application/javascript' }); const blob = new Blob([swCode], { type: 'application/javascript' });
const swUrl = URL.createObjectURL(blob); 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); this.keyPair = await this.importKeyPair(keyData);
return this.keyPair; return this.keyPair;
} catch (error) { } catch (error) {
console.warn('Failed to load stored key, generating new one:', error);
localStorage.removeItem('oauth_private_key'); localStorage.removeItem('oauth_private_key');
} }
} }
@@ -115,7 +114,6 @@ export class OAuthKeyManager {
const privateKey = await window.crypto.subtle.exportKey('jwk', keyPair.privateKey); const privateKey = await window.crypto.subtle.exportKey('jwk', keyPair.privateKey);
localStorage.setItem('oauth_private_key', JSON.stringify(privateKey)); localStorage.setItem('oauth_private_key', JSON.stringify(privateKey));
} catch (error) { } 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);
}

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

View File

@@ -86,7 +86,125 @@ fn get_config_path() -> Result<PathBuf> {
Ok(config_dir.join("config.json")) Ok(config_dir.join("config.json"))
} }
#[allow(dead_code)]
pub async fn init() -> Result<()> { 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()); println!("{}", "🔐 Initializing ATProto authentication...".cyan());
let config_path = get_config_path()?; let config_path = get_config_path()?;
@@ -117,9 +235,28 @@ pub async fn init() -> Result<()> {
std::io::stdin().read_line(&mut refresh_jwt)?; std::io::stdin().read_line(&mut refresh_jwt)?;
let refresh_jwt = refresh_jwt.trim().to_string(); 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 // Resolve DID from handle
println!("{}", "🔍 Resolving DID from handle...".cyan()); println!("{}", "🔍 Resolving DID from handle...".cyan());
let did = resolve_did(&handle).await?; let did = resolve_did_with_pds(&handle, &pds_url).await?;
// Create config // Create config
let config = AuthConfig { let config = AuthConfig {
@@ -128,11 +265,7 @@ pub async fn init() -> Result<()> {
handle: handle.clone(), handle: handle.clone(),
access_jwt, access_jwt,
refresh_jwt, refresh_jwt,
pds: if handle.ends_with(".syu.is") { pds: pds_url,
"https://syu.is".to_string()
} else {
"https://bsky.social".to_string()
},
}, },
jetstream: JetstreamConfig { jetstream: JetstreamConfig {
url: "wss://jetstream2.us-east.bsky.network/subscribe".to_string(), url: "wss://jetstream2.us-east.bsky.network/subscribe".to_string(),
@@ -152,10 +285,19 @@ pub async fn init() -> Result<()> {
Ok(()) Ok(())
} }
#[allow(dead_code)]
async fn resolve_did(handle: &str) -> Result<String> { async fn resolve_did(handle: &str) -> Result<String> {
let client = reqwest::Client::new(); 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?; let response = client.get(&url).send().await?;
@@ -170,6 +312,93 @@ async fn resolve_did(handle: &str) -> Result<String> {
Ok(did.to_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<()> { pub async fn status() -> Result<()> {
let config_path = get_config_path()?; let config_path = get_config_path()?;
@@ -192,9 +421,17 @@ pub async fn status() -> Result<()> {
// Test API access // Test API access
println!("\n{}", "🧪 Testing API access...".cyan()); 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()), 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(()) Ok(())
@@ -202,8 +439,16 @@ pub async fn status() -> Result<()> {
async fn test_api_access(config: &AuthConfig) -> Result<()> { async fn test_api_access(config: &AuthConfig) -> Result<()> {
let client = reqwest::Client::new(); 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?; let response = client.get(&url).send().await?;

View File

@@ -1,6 +1,7 @@
use anyhow::Result; use anyhow::Result;
use colored::Colorize; use colored::Colorize;
use std::path::PathBuf; use std::path::PathBuf;
use std::fs;
use crate::generator::Generator; use crate::generator::Generator;
use crate::config::Config; use crate::config::Config;
@@ -10,6 +11,12 @@ pub async fn execute(path: PathBuf) -> Result<()> {
// Load configuration // Load configuration
let config = Config::load(&path)?; 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 // Create generator
let generator = Generator::new(path, config)?; let generator = Generator::new(path, config)?;
@@ -18,5 +25,104 @@ pub async fn execute(path: PathBuf) -> Result<()> {
println!("{}", "Build completed successfully!".green().bold()); 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(()) Ok(())
} }

View File

@@ -37,9 +37,23 @@ highlight_code = true
minify = false minify = false
[ai] [ai]
enabled = false enabled = true
auto_translate = false auto_translate = false
comment_moderation = 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)?; fs::write(path.join("config.toml"), config_content)?;

View File

@@ -3,6 +3,8 @@ use std::path::{Path, PathBuf};
use std::fs; use std::fs;
use std::process::Command; use std::process::Command;
use toml::Value; use toml::Value;
use serde_json;
use reqwest;
pub async fn build(project_dir: PathBuf) -> Result<()> { pub async fn build(project_dir: PathBuf) -> Result<()> {
println!("Building OAuth app for project: {}", project_dir.display()); println!("Building OAuth app for project: {}", project_dir.display());
@@ -41,68 +43,102 @@ pub async fn build(project_dir: PathBuf) -> Result<()> {
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or("oauth/callback"); .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()) .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_base = oauth_config.get("collection") let collection_base = oauth_config.get("collection")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or("ai.syui.log"); .unwrap_or("ai.syui.log");
// Extract AI config if present // Get handle list for authentication restriction
let ai_config = config.get("ai") let handle_list = oauth_config.get("handle_list")
.and_then(|v| v.as_table()); .and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str()).collect::<Vec<&str>>())
.unwrap_or_else(|| vec![]);
// 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("yui.syui.ai");
let ai_enabled = ai_config 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()) .and_then(|v| v.as_bool())
.unwrap_or(false); .unwrap_or(true);
let ai_ask_ai = ai_config 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()) .and_then(|v| v.as_bool())
.unwrap_or(false); .unwrap_or(true);
let ai_provider = ai_config 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()) .and_then(|v| v.as_str())
.unwrap_or("ollama"); .unwrap_or("ollama");
let ai_model = ai_config 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()) .and_then(|v| v.as_str())
.unwrap_or("gemma2:2b"); .unwrap_or("gemma3:4b");
let ai_host = ai_config 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()) .and_then(|v| v.as_str())
.unwrap_or("https://ollama.syui.ai"); .unwrap_or("https://ollama.syui.ai");
let ai_system_prompt = ai_config 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()) .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 let (bsky_api, _atproto_api, web_url) = match pds {
.and_then(|ai| ai.get("ai_did")) "syu.is" => (
.and_then(|v| v.as_str()) "https://bsky.syu.is",
.unwrap_or("did:plc:4hqjfn7m6n5hno3doamuhgef"); "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 // Resolve handles to DIDs using appropriate API
let bsky_api = oauth_config.get("bsky_api") println!("🔍 Resolving admin handle: {}", admin_handle);
.and_then(|v| v.as_str()) let admin_did = resolve_handle_to_did(admin_handle, &bsky_api).await
.unwrap_or("https://public.api.bsky.app"); .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!( let env_content = format!(
r#"# Production environment variables r#"# Production environment variables
VITE_APP_HOST={} VITE_APP_HOST={}
VITE_OAUTH_CLIENT_ID={}/{} VITE_OAUTH_CLIENT_ID={}/{}
VITE_OAUTH_REDIRECT_URI={}/{} VITE_OAUTH_REDIRECT_URI={}/{}
VITE_ADMIN_DID={}
# Base collection for OAuth app and ailog (all others are derived) # Handle-based Configuration (DIDs resolved at runtime)
VITE_ATPROTO_PDS={}
VITE_ADMIN_HANDLE={}
VITE_AI_HANDLE={}
VITE_OAUTH_COLLECTION={} VITE_OAUTH_COLLECTION={}
VITE_ATPROTO_WEB_URL={}
VITE_ATPROTO_HANDLE_LIST={}
# AI Configuration # AI Configuration
VITE_AI_ENABLED={} VITE_AI_ENABLED={}
@@ -111,24 +147,28 @@ VITE_AI_PROVIDER={}
VITE_AI_MODEL={} VITE_AI_MODEL={}
VITE_AI_HOST={} VITE_AI_HOST={}
VITE_AI_SYSTEM_PROMPT="{}" VITE_AI_SYSTEM_PROMPT="{}"
VITE_AI_DID={}
# API Configuration # DIDs (resolved from handles - for backward compatibility)
VITE_BSKY_PUBLIC_API={} #VITE_ADMIN_DID={}
#VITE_AI_DID={}
"#, "#,
base_url, base_url,
base_url, client_id_path, base_url, client_id_path,
base_url, redirect_path, base_url, redirect_path,
admin_did, pds,
admin_handle,
ai_handle,
collection_base, collection_base,
web_url,
format!("[{}]", handle_list.iter().map(|h| format!("\"{}\"", h)).collect::<Vec<_>>().join(",")),
ai_enabled, ai_enabled,
ai_ask_ai, ai_ask_ai,
ai_provider, ai_provider,
ai_model, ai_model,
ai_host, ai_host,
ai_system_prompt, ai_system_prompt,
ai_did, admin_did,
bsky_api ai_did
); );
// 5. Find oauth directory (relative to current working directory) // 5. Find oauth directory (relative to current working directory)
@@ -239,4 +279,60 @@ fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
} }
Ok(()) 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)
} }

View File

@@ -14,6 +14,74 @@ use reqwest;
use super::auth::{load_config, load_config_with_refresh, AuthConfig}; use super::auth::{load_config, load_config_with_refresh, AuthConfig};
// PDS-based network configuration mapping
fn get_network_config(pds: &str) -> NetworkConfig {
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(),
}
}
}
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
struct NetworkConfig {
pds_api: String,
plc_api: String,
bsky_api: String,
web_url: String,
}
#[derive(Debug, Clone)]
struct AiConfig {
blog_host: String,
ollama_host: String,
#[allow(dead_code)]
ai_handle: String,
ai_did: String, // Resolved from ai_handle at runtime
model: String,
system_prompt: String,
#[allow(dead_code)]
bsky_api: String,
num_predict: Option<i32>,
network: NetworkConfig,
}
impl Default for AiConfig {
fn default() -> Self {
let default_network = get_network_config("bsky.social");
Self {
blog_host: "https://syui.ai".to_string(),
ollama_host: "https://ollama.syui.ai".to_string(),
ai_handle: "ai.syui.ai".to_string(),
ai_did: "did:plc:6qyecktefllvenje24fcxnie".to_string(), // Fallback DID
model: "gemma3:4b".to_string(),
system_prompt: "あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。相手のことが大好きで、ときどき甘えたり、照れたりします。でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。".to_string(),
bsky_api: default_network.bsky_api.clone(),
num_predict: None,
network: default_network,
}
}
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[allow(dead_code)] #[allow(dead_code)]
struct BlogPost { struct BlogPost {
@@ -112,6 +180,130 @@ fn load_collection_config_from_project(project_dir: &Path) -> Result<(String, St
Ok((collection_base, collection_user)) Ok((collection_base, collection_user))
} }
// Load AI config from project's config.toml with optional project directory
fn load_ai_config_from_project_dir(project_dir: Option<&Path>) -> Result<AiConfig> {
let search_start = if let Some(dir) = project_dir {
dir.to_path_buf()
} else {
std::env::current_dir()?
};
// Try to find config.toml in specified directory or parent directories
let mut current_dir = search_start;
let mut config_path = None;
for _ in 0..5 { // Search up to 5 levels up
let potential_config = current_dir.join("config.toml");
if potential_config.exists() {
config_path = Some(potential_config);
break;
}
if !current_dir.pop() {
break;
}
}
let config_path = config_path.ok_or_else(|| anyhow::anyhow!("config.toml not found in specified directory or parent directories"))?;
let config_content = fs::read_to_string(&config_path)
.with_context(|| format!("Failed to read config.toml from {}", config_path.display()))?;
let config: toml::Value = config_content.parse()
.with_context(|| "Failed to parse config.toml")?;
// Extract site config
let site_config = config.get("site").and_then(|v| v.as_table());
let blog_host = site_config
.and_then(|s| s.get("base_url"))
.and_then(|v| v.as_str())
.unwrap_or("https://syui.ai")
.to_string();
// Extract AI config
let ai_config = config.get("ai").and_then(|v| v.as_table());
let ollama_host = ai_config
.and_then(|ai| ai.get("host"))
.and_then(|v| v.as_str())
.unwrap_or("https://ollama.syui.ai")
.to_string();
// Read AI handle (preferred) or fallback to AI DID
let ai_handle = ai_config
.and_then(|ai| ai.get("handle"))
.and_then(|v| v.as_str())
.unwrap_or("ai.syui.ai")
.to_string();
let fallback_ai_did = ai_config
.and_then(|ai| ai.get("ai_did"))
.and_then(|v| v.as_str())
.unwrap_or("did:plc:6qyecktefllvenje24fcxnie")
.to_string();
let model = ai_config
.and_then(|ai| ai.get("model"))
.and_then(|v| v.as_str())
.unwrap_or("gemma3:4b")
.to_string();
let system_prompt = ai_config
.and_then(|ai| ai.get("system_prompt"))
.and_then(|v| v.as_str())
.unwrap_or("あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。相手のことが大好きで、ときどき甘えたり、照れたりします。でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。")
.to_string();
let num_predict = ai_config
.and_then(|ai| ai.get("num_predict"))
.and_then(|v| v.as_integer())
.map(|v| v as i32);
// Extract OAuth config to determine network
let oauth_config = config.get("oauth").and_then(|v| v.as_table());
let pds = oauth_config
.and_then(|oauth| oauth.get("pds"))
.and_then(|v| v.as_str())
.unwrap_or("syu.is")
.to_string();
let network = get_network_config(&pds);
Ok(AiConfig {
blog_host,
ollama_host,
ai_handle,
ai_did: fallback_ai_did,
model,
system_prompt,
bsky_api: network.bsky_api.clone(),
num_predict,
network,
})
}
// Load AI config from project's config.toml
fn load_ai_config_from_project() -> Result<AiConfig> {
load_ai_config_from_project_dir(None)
}
// Async version of load_ai_config_from_project that resolves handles to DIDs
#[allow(dead_code)]
async fn load_ai_config_with_did_resolution() -> Result<AiConfig> {
let mut ai_config = load_ai_config_from_project()?;
// Resolve AI handle to DID
match resolve_handle(&ai_config.ai_handle, &ai_config.network).await {
Ok(resolved_did) => {
ai_config.ai_did = resolved_did;
println!("🔍 Resolved AI handle '{}' to DID: {}", ai_config.ai_handle, ai_config.ai_did);
}
Err(e) => {
println!("⚠️ Failed to resolve AI handle '{}': {}. Using fallback DID.", ai_config.ai_handle, e);
}
}
Ok(ai_config)
}
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
struct JetstreamMessage { struct JetstreamMessage {
collection: Option<String>, collection: Option<String>,
@@ -157,6 +349,104 @@ fn get_pid_file() -> Result<PathBuf> {
Ok(pid_dir.join("stream.pid")) Ok(pid_dir.join("stream.pid"))
} }
pub async fn init_user_list(project_dir: Option<PathBuf>, handles: Option<String>) -> Result<()> {
println!("{}", "🔧 Initializing user list...".cyan());
// Load auth config
let mut config = match load_config_with_refresh().await {
Ok(config) => config,
Err(e) => {
println!("{}", format!("❌ Not authenticated: {}. Run 'ailog auth init --pds <PDS>' first.", e).red());
return Ok(());
}
};
println!("{}", format!("📋 Admin: {} ({})", config.admin.handle, config.admin.did).cyan());
println!("{}", format!("🌐 PDS: {}", config.admin.pds).cyan());
let mut users = Vec::new();
// Parse handles if provided
if let Some(handles_str) = handles {
println!("{}", "🔍 Resolving provided handles...".cyan());
let handle_list: Vec<&str> = handles_str.split(',').map(|s| s.trim()).collect();
for handle in handle_list {
if handle.is_empty() {
continue;
}
println!(" 🏷️ Resolving handle: {}", handle);
// Get AI config to determine network settings
let ai_config = if let Some(ref proj_dir) = project_dir {
let current_dir = std::env::current_dir()?;
std::env::set_current_dir(proj_dir)?;
let config = load_ai_config_from_project().unwrap_or_default();
std::env::set_current_dir(current_dir)?;
config
} else {
load_ai_config_from_project().unwrap_or_default()
};
// Try to resolve handle to DID
match resolve_handle_to_did(handle, &ai_config.network).await {
Ok(did) => {
println!(" ✅ DID: {}", did.cyan());
// Detect PDS for this user using proper detection
let detected_pds = detect_user_pds(&did, &ai_config.network).await
.unwrap_or_else(|_| {
// Fallback to handle-based detection
if handle.ends_with(".syu.is") {
"https://syu.is".to_string()
} else {
"https://bsky.social".to_string()
}
});
users.push(UserRecord {
did,
handle: handle.to_string(),
pds: detected_pds,
});
}
Err(e) => {
println!(" ❌ Failed to resolve {}: {}", handle, e);
}
}
}
} else {
println!("{}", " No handles provided, creating empty user list".blue());
}
// Create the initial user list
println!("{}", format!("📝 Creating user list with {} users...", users.len()).cyan());
match post_user_list(&mut config, &users, json!({
"reason": "initial_setup",
"created_by": "ailog_stream_init"
})).await {
Ok(_) => println!("{}", "✅ User list created successfully!".green()),
Err(e) => {
println!("{}", format!("❌ Failed to create user list: {}", e).red());
return Err(e);
}
}
// Show summary
if users.is_empty() {
println!("{}", "📋 Empty user list created. Use 'ailog stream start --ai-generate' to auto-add commenters.".blue());
} else {
println!("{}", "📋 User list contents:".cyan());
for user in &users {
println!(" 👤 {} ({})", user.handle, user.did);
}
}
Ok(())
}
pub async fn start(project_dir: Option<PathBuf>, daemon: bool, ai_generate: bool) -> Result<()> { pub async fn start(project_dir: Option<PathBuf>, daemon: bool, ai_generate: bool) -> Result<()> {
let mut config = load_config_with_refresh().await?; let mut config = load_config_with_refresh().await?;
@@ -238,9 +528,10 @@ pub async fn start(project_dir: Option<PathBuf>, daemon: bool, ai_generate: bool
// Start AI generation monitor if enabled // Start AI generation monitor if enabled
if ai_generate { if ai_generate {
let ai_config = config.clone(); let ai_config = config.clone();
let project_path = project_dir.clone();
tokio::spawn(async move { tokio::spawn(async move {
loop { loop {
if let Err(e) = run_ai_generation_monitor(&ai_config).await { if let Err(e) = run_ai_generation_monitor(&ai_config, project_path.as_deref()).await {
println!("{}", format!("❌ AI generation monitor error: {}", e).red()); println!("{}", format!("❌ AI generation monitor error: {}", e).red());
sleep(Duration::from_secs(60)).await; // Wait 1 minute before retry sleep(Duration::from_secs(60)).await; // Wait 1 minute before retry
} }
@@ -409,7 +700,8 @@ async fn handle_message(text: &str, config: &mut AuthConfig) -> Result<()> {
println!(" 👤 Author DID: {}", did); println!(" 👤 Author DID: {}", did);
// Resolve handle // Resolve handle
match resolve_handle(did).await { let ai_config = load_ai_config_from_project().unwrap_or_default();
match resolve_handle(did, &ai_config.network).await {
Ok(handle) => { Ok(handle) => {
println!(" 🏷️ Handle: {}", handle.cyan()); println!(" 🏷️ Handle: {}", handle.cyan());
@@ -430,10 +722,37 @@ async fn handle_message(text: &str, config: &mut AuthConfig) -> Result<()> {
Ok(()) Ok(())
} }
async fn resolve_handle(did: &str) -> Result<String> { async fn resolve_handle(did: &str, _network: &NetworkConfig) -> Result<String> {
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let url = format!("https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor={}",
urlencoding::encode(did)); // First try to resolve PDS from DID using com.atproto.repo.describeRepo
let pds_endpoints = ["https://bsky.social", "https://syu.is"];
let mut resolved_pds = None;
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::<Value>().await {
if let Some(services) = data["didDoc"]["service"].as_array() {
if let Some(pds_service) = services.iter().find(|s|
s["id"] == "#atproto_pds" || s["type"] == "AtprotoPersonalDataServer"
) {
if let Some(endpoint) = pds_service["serviceEndpoint"].as_str() {
resolved_pds = Some(get_network_config_from_pds(endpoint));
break;
}
}
}
}
}
}
}
// Use resolved PDS or fallback to Bluesky
let network_config = resolved_pds.unwrap_or_else(|| get_network_config("bsky.social"));
let url = format!("{}/xrpc/app.bsky.actor.getProfile?actor={}",
network_config.bsky_api, urlencoding::encode(did));
let response = client.get(&url).send().await?; let response = client.get(&url).send().await?;
@@ -448,6 +767,53 @@ async fn resolve_handle(did: &str) -> Result<String> {
Ok(handle.to_string()) Ok(handle.to_string())
} }
// Helper function to get network config from PDS endpoint
fn get_network_config_from_pds(pds_endpoint: &str) -> NetworkConfig {
if pds_endpoint.contains("syu.is") {
NetworkConfig {
pds_api: pds_endpoint.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(),
}
} else {
// Default to Bluesky infrastructure
NetworkConfig {
pds_api: pds_endpoint.to_string(),
plc_api: "https://plc.directory".to_string(),
bsky_api: "https://public.api.bsky.app".to_string(),
web_url: "https://bsky.app".to_string(),
}
}
}
async fn detect_user_pds(did: &str, _network_config: &NetworkConfig) -> Result<String> {
let client = reqwest::Client::new();
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::<Value>().await {
if let Some(services) = data["didDoc"]["service"].as_array() {
if let Some(pds_service) = services.iter().find(|s|
s["id"] == "#atproto_pds" || s["type"] == "AtprotoPersonalDataServer"
) {
if let Some(endpoint) = pds_service["serviceEndpoint"].as_str() {
return Ok(endpoint.to_string());
}
}
}
}
}
}
}
// Fallback to default
Ok("https://bsky.social".to_string())
}
async fn update_user_list(config: &mut AuthConfig, did: &str, handle: &str) -> Result<()> { async fn update_user_list(config: &mut AuthConfig, did: &str, handle: &str) -> Result<()> {
// Get current user list // Get current user list
let current_users = get_current_user_list(config).await?; let current_users = get_current_user_list(config).await?;
@@ -460,18 +826,36 @@ async fn update_user_list(config: &mut AuthConfig, did: &str, handle: &str) -> R
println!(" Adding new user to list: {}", handle.green()); println!(" Adding new user to list: {}", handle.green());
// Detect PDS // Detect PDS using proper resolution from DID
let pds = if handle.ends_with(".syu.is") { let client = reqwest::Client::new();
"https://syu.is" let pds_endpoints = ["https://bsky.social", "https://syu.is"];
} else { let mut detected_pds = "https://bsky.social".to_string(); // Default fallback
"https://bsky.social"
}; 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::<Value>().await {
if let Some(services) = data["didDoc"]["service"].as_array() {
if let Some(pds_service) = services.iter().find(|s|
s["id"] == "#atproto_pds" || s["type"] == "AtprotoPersonalDataServer"
) {
if let Some(endpoint) = pds_service["serviceEndpoint"].as_str() {
detected_pds = endpoint.to_string();
break;
}
}
}
}
}
}
}
// Add new user // Add new user
let new_user = UserRecord { let new_user = UserRecord {
did: did.to_string(), did: did.to_string(),
handle: handle.to_string(), handle: handle.to_string(),
pds: pds.to_string(), pds: detected_pds,
}; };
let mut updated_users = current_users; let mut updated_users = current_users;
@@ -782,7 +1166,8 @@ async fn poll_comments_periodically(mut config: AuthConfig) -> Result<()> {
println!(" 👤 Author DID: {}", did); println!(" 👤 Author DID: {}", did);
// Resolve handle and update user list // Resolve handle and update user list
match resolve_handle(&did).await { let ai_config = load_ai_config_from_project().unwrap_or_default();
match resolve_handle(&did, &ai_config.network).await {
Ok(handle) => { Ok(handle) => {
println!(" 🏷️ Handle: {}", handle.cyan()); println!(" 🏷️ Handle: {}", handle.cyan());
@@ -880,6 +1265,68 @@ fn extract_did_from_uri(uri: &str) -> Option<String> {
None None
} }
// OAuth config structure for loading admin settings
#[derive(Debug)]
struct OAuthConfig {
admin: String,
pds: Option<String>,
}
// Load OAuth config from project's config.toml
fn load_oauth_config_from_project() -> Option<OAuthConfig> {
// Try to find config.toml in current directory or parent directories
let mut current_dir = std::env::current_dir().ok()?;
let mut config_path = None;
for _ in 0..5 { // Search up to 5 levels up
let potential_config = current_dir.join("config.toml");
if potential_config.exists() {
config_path = Some(potential_config);
break;
}
if !current_dir.pop() {
break;
}
}
let config_path = config_path?;
let config_content = std::fs::read_to_string(&config_path).ok()?;
let config: toml::Value = config_content.parse().ok()?;
let oauth_config = config.get("oauth").and_then(|v| v.as_table())?;
let admin = oauth_config
.get("admin")
.and_then(|v| v.as_str())?
.to_string();
let pds = oauth_config
.get("pds")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
Some(OAuthConfig { admin, pds })
}
// Resolve handle to DID using PLC directory
async fn resolve_handle_to_did(handle: &str, network_config: &NetworkConfig) -> Result<String> {
let client = reqwest::Client::new();
let url = format!("{}/xrpc/com.atproto.identity.resolveHandle?handle={}",
network_config.bsky_api, urlencoding::encode(handle));
let response = client.get(&url).send().await?;
if !response.status().is_success() {
return Err(anyhow::anyhow!("Failed to resolve handle: {}", response.status()));
}
let data: Value = response.json().await?;
let did = data["did"].as_str()
.ok_or_else(|| anyhow::anyhow!("DID not found in response"))?;
Ok(did.to_string())
}
pub async fn test_api() -> Result<()> { pub async fn test_api() -> Result<()> {
println!("{}", "🧪 Testing API access to comments collection...".cyan().bold()); println!("{}", "🧪 Testing API access to comments collection...".cyan().bold());
@@ -931,27 +1378,53 @@ pub async fn test_api() -> Result<()> {
} }
// AI content generation functions // AI content generation functions
async fn generate_ai_content(content: &str, prompt_type: &str, ollama_host: &str) -> Result<String> { async fn generate_ai_content(content: &str, prompt_type: &str, ai_config: &AiConfig) -> Result<String> {
let model = "gemma3:4b"; let model = &ai_config.model;
let system_prompt = &ai_config.system_prompt;
let prompt = match prompt_type { let prompt = match prompt_type {
"translate" => format!("Translate the following Japanese blog post to English. Keep the technical terms and code blocks intact:\n\n{}", content), "translate" => format!(
"comment" => format!("Read this blog post and provide an insightful comment about it. Focus on the key points and add your perspective:\n\n{}", content), "{}\n\n# 指示\n以下の日本語ブログ記事を英語に翻訳してください。\n- 技術用語やコードブロックはそのまま維持\n- アイらしい表現で翻訳\n- 簡潔に要点をまとめる\n\n# ブログ記事\n{}",
system_prompt, content
),
"comment" => {
// Limit content to first 500 characters to reduce input size
let limited_content = if content.len() > 500 {
format!("{}...", &content[..500])
} else {
content.to_string()
};
format!(
"{}\n\n# 指示\nこのブログ記事を読んで、アイらしい感想をください。\n- 100文字以内の感想\n- 技術的な内容への素朴な驚きや発見\n- アイらしい感嘆詞で始める\n- 簡潔で分かりやすく\n\n# ブログ記事(要約)\n{}\n\n# 出力形式\n感想のみ(説明や詳細は不要):",
system_prompt, limited_content
)
},
_ => return Err(anyhow::anyhow!("Unknown prompt type: {}", prompt_type)), _ => return Err(anyhow::anyhow!("Unknown prompt type: {}", prompt_type)),
}; };
let num_predict = ai_config.num_predict.unwrap_or_else(|| {
match prompt_type {
"comment" => 150, // Longer for comments (about 100 characters)
"translate" => 3000, // Much longer for translations
_ => 300,
}
});
let request = OllamaRequest { let request = OllamaRequest {
model: model.to_string(), model: model.to_string(),
prompt, prompt,
stream: false, stream: false,
options: OllamaOptions { options: OllamaOptions {
temperature: 0.9, temperature: 0.7, // Lower temperature for more focused responses
top_p: 0.9, top_p: 0.8,
num_predict: 500, num_predict,
}, },
}; };
let client = reqwest::Client::new(); let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(120)) // 2 minute timeout
.build()?;
// Try localhost first (for same-server deployment) // Try localhost first (for same-server deployment)
let localhost_url = "http://localhost:11434/api/generate"; let localhost_url = "http://localhost:11434/api/generate";
@@ -967,8 +1440,37 @@ async fn generate_ai_content(content: &str, prompt_type: &str, ollama_host: &str
} }
// Fallback to remote host // Fallback to remote host
let remote_url = format!("{}/api/generate", ollama_host); let remote_url = format!("{}/api/generate", ai_config.ollama_host);
let response = client.post(&remote_url).json(&request).send().await?;
// Check if this is a local/private network connection (no CORS needed)
// RFC 1918 private networks + localhost
let is_local = ai_config.ollama_host.contains("localhost") ||
ai_config.ollama_host.contains("127.0.0.1") ||
ai_config.ollama_host.contains("::1") ||
ai_config.ollama_host.contains("192.168.") || // 192.168.0.0/16
ai_config.ollama_host.contains("10.") || // 10.0.0.0/8
(ai_config.ollama_host.contains("172.") && { // 172.16.0.0/12
// Extract 172.x and check if x is 16-31
if let Some(start) = ai_config.ollama_host.find("172.") {
let after_172 = &ai_config.ollama_host[start + 4..];
if let Some(dot_pos) = after_172.find('.') {
if let Ok(second_octet) = after_172[..dot_pos].parse::<u8>() {
second_octet >= 16 && second_octet <= 31
} else { false }
} else { false }
} else { false }
});
let mut request_builder = client.post(&remote_url).json(&request);
if !is_local {
println!("{}", format!("🔗 Making request to: {} with Origin: {}", remote_url, ai_config.blog_host).blue());
request_builder = request_builder.header("Origin", &ai_config.blog_host);
} else {
println!("{}", format!("🔗 Making request to local network: {}", remote_url).blue());
}
let response = request_builder.send().await?;
if !response.status().is_success() { if !response.status().is_success() {
return Err(anyhow::anyhow!("Ollama API request failed: {}", response.status())); return Err(anyhow::anyhow!("Ollama API request failed: {}", response.status()));
@@ -979,10 +1481,16 @@ async fn generate_ai_content(content: &str, prompt_type: &str, ollama_host: &str
Ok(ollama_response.response) Ok(ollama_response.response)
} }
async fn run_ai_generation_monitor(config: &AuthConfig) -> Result<()> { async fn run_ai_generation_monitor(config: &AuthConfig, project_dir: Option<&Path>) -> Result<()> {
let blog_host = "https://syui.ai"; // TODO: Load from config // Load AI config from project config.toml or use defaults
let ollama_host = "https://ollama.syui.ai"; // TODO: Load from config let ai_config = load_ai_config_from_project_dir(project_dir).unwrap_or_else(|e| {
let ai_did = "did:plc:4hqjfn7m6n5hno3doamuhgef"; // TODO: Load from config println!("{}", format!("⚠️ Failed to load AI config: {}, using defaults", e).yellow());
AiConfig::default()
});
let blog_host = &ai_config.blog_host;
let ollama_host = &ai_config.ollama_host;
let ai_did = &ai_config.ai_did;
println!("{}", "🤖 Starting AI content generation monitor...".cyan()); println!("{}", "🤖 Starting AI content generation monitor...".cyan());
println!("📡 Blog host: {}", blog_host); println!("📡 Blog host: {}", blog_host);
@@ -998,7 +1506,7 @@ async fn run_ai_generation_monitor(config: &AuthConfig) -> Result<()> {
println!("{}", "🔍 Checking for new blog posts...".blue()); println!("{}", "🔍 Checking for new blog posts...".blue());
match check_and_process_new_posts(&client, config, blog_host, ollama_host, ai_did).await { match check_and_process_new_posts(&client, config, &ai_config).await {
Ok(count) => { Ok(count) => {
if count > 0 { if count > 0 {
println!("{}", format!("✅ Processed {} new posts", count).green()); println!("{}", format!("✅ Processed {} new posts", count).green());
@@ -1018,12 +1526,10 @@ async fn run_ai_generation_monitor(config: &AuthConfig) -> Result<()> {
async fn check_and_process_new_posts( async fn check_and_process_new_posts(
client: &reqwest::Client, client: &reqwest::Client,
config: &AuthConfig, config: &AuthConfig,
blog_host: &str, ai_config: &AiConfig,
ollama_host: &str,
ai_did: &str,
) -> Result<usize> { ) -> Result<usize> {
// Fetch blog index // Fetch blog index
let index_url = format!("{}/index.json", blog_host); let index_url = format!("{}/index.json", ai_config.blog_host);
let response = client.get(&index_url).send().await?; let response = client.get(&index_url).send().await?;
if !response.status().is_success() { if !response.status().is_success() {
@@ -1042,25 +1548,57 @@ async fn check_and_process_new_posts(
for post in blog_posts { for post in blog_posts {
let post_slug = extract_slug_from_url(&post.href); let post_slug = extract_slug_from_url(&post.href);
// Check if translation already exists // Check if translation already exists (support both old and new format)
let translation_exists = existing_lang_records.iter().any(|record| { let translation_exists = existing_lang_records.iter().any(|record| {
record.get("value") let value = record.get("value");
// Check new format: value.post.slug
let new_format_match = value
.and_then(|v| v.get("post"))
.and_then(|p| p.get("slug"))
.and_then(|s| s.as_str())
== Some(&post_slug);
// Check old format: value.post_slug
let old_format_match = value
.and_then(|v| v.get("post_slug")) .and_then(|v| v.get("post_slug"))
.and_then(|s| s.as_str()) .and_then(|s| s.as_str())
== Some(&post_slug) == Some(&post_slug);
new_format_match || old_format_match
}); });
// Check if comment already exists if translation_exists {
println!("{}", format!("⏭️ Translation already exists for: {}", post.title).yellow());
}
// Check if comment already exists (support both old and new format)
let comment_exists = existing_comment_records.iter().any(|record| { let comment_exists = existing_comment_records.iter().any(|record| {
record.get("value") let value = record.get("value");
// Check new format: value.post.slug
let new_format_match = value
.and_then(|v| v.get("post"))
.and_then(|p| p.get("slug"))
.and_then(|s| s.as_str())
== Some(&post_slug);
// Check old format: value.post_slug
let old_format_match = value
.and_then(|v| v.get("post_slug")) .and_then(|v| v.get("post_slug"))
.and_then(|s| s.as_str()) .and_then(|s| s.as_str())
== Some(&post_slug) == Some(&post_slug);
new_format_match || old_format_match
}); });
if comment_exists {
println!("{}", format!("⏭️ Comment already exists for: {}", post.title).yellow());
}
// Generate translation if not exists // Generate translation if not exists
if !translation_exists { if !translation_exists {
match generate_and_store_translation(client, config, &post, ollama_host, ai_did).await { match generate_and_store_translation(client, config, &post, ai_config).await {
Ok(_) => { Ok(_) => {
println!("{}", format!("✅ Generated translation for: {}", post.title).green()); println!("{}", format!("✅ Generated translation for: {}", post.title).green());
processed_count += 1; processed_count += 1;
@@ -1069,11 +1607,13 @@ async fn check_and_process_new_posts(
println!("{}", format!("❌ Failed to generate translation for {}: {}", post.title, e).red()); println!("{}", format!("❌ Failed to generate translation for {}: {}", post.title, e).red());
} }
} }
} else {
println!("{}", format!("⏭️ Translation already exists for: {}", post.title).yellow());
} }
// Generate comment if not exists // Generate comment if not exists
if !comment_exists { if !comment_exists {
match generate_and_store_comment(client, config, &post, ollama_host, ai_did).await { match generate_and_store_comment(client, config, &post, ai_config).await {
Ok(_) => { Ok(_) => {
println!("{}", format!("✅ Generated comment for: {}", post.title).green()); println!("{}", format!("✅ Generated comment for: {}", post.title).green());
processed_count += 1; processed_count += 1;
@@ -1082,6 +1622,8 @@ async fn check_and_process_new_posts(
println!("{}", format!("❌ Failed to generate comment for {}: {}", post.title, e).red()); println!("{}", format!("❌ Failed to generate comment for {}: {}", post.title, e).red());
} }
} }
} else {
println!("{}", format!("⏭️ Comment already exists for: {}", post.title).yellow());
} }
} }
@@ -1120,25 +1662,112 @@ fn extract_slug_from_url(url: &str) -> String {
.to_string() .to_string()
} }
fn extract_date_from_slug(slug: &str) -> String {
// Extract date from slug like "2025-06-14-blog" -> "2025-06-14T00:00:00Z"
if slug.len() >= 10 && slug.chars().nth(4) == Some('-') && slug.chars().nth(7) == Some('-') {
format!("{}T00:00:00Z", &slug[0..10])
} else {
chrono::Utc::now().format("%Y-%m-%dT00:00:00Z").to_string()
}
}
async fn get_ai_profile(client: &reqwest::Client, ai_config: &AiConfig) -> Result<serde_json::Value> {
let handle = &ai_config.ai_handle;
// First, try to resolve PDS from handle using the admin's configured PDS
let mut network_config = ai_config.network.clone();
// For admin/ai handles matching configured PDS, use the configured network
if let Some(oauth_config) = load_oauth_config_from_project() {
if handle == &oauth_config.admin {
// Use configured PDS for admin handle
let pds = oauth_config.pds.unwrap_or_else(|| "syu.is".to_string());
network_config = get_network_config(&pds);
}
}
// Get profile from appropriate bsky API
let url = format!("{}/xrpc/app.bsky.actor.getProfile?actor={}",
network_config.bsky_api, urlencoding::encode(handle));
let response = client
.get(&url)
.send()
.await?;
if !response.status().is_success() {
// Try to resolve DID first, then retry with DID
match resolve_handle_to_did(handle, &network_config).await {
Ok(resolved_did) => {
// Retry with resolved DID
let did_url = format!("{}/xrpc/app.bsky.actor.getProfile?actor={}",
network_config.bsky_api, urlencoding::encode(&resolved_did));
let did_response = client.get(&did_url).send().await?;
if did_response.status().is_success() {
let profile_data: serde_json::Value = did_response.json().await?;
return Ok(serde_json::json!({
"did": resolved_did,
"handle": profile_data["handle"].as_str().unwrap_or(handle),
"displayName": profile_data["displayName"].as_str().unwrap_or("ai"),
"avatar": profile_data["avatar"].as_str()
}));
}
}
Err(_) => {}
}
// Final fallback to default AI profile
return Ok(serde_json::json!({
"did": ai_config.ai_did,
"handle": handle,
"displayName": "ai",
"avatar": format!("https://api.dicebear.com/7.x/bottts-neutral/svg?seed={}", handle)
}));
}
let profile_data: serde_json::Value = response.json().await?;
Ok(serde_json::json!({
"did": profile_data["did"].as_str().unwrap_or(&ai_config.ai_did),
"handle": profile_data["handle"].as_str().unwrap_or(handle),
"displayName": profile_data["displayName"].as_str().unwrap_or("ai"),
"avatar": profile_data["avatar"].as_str()
}))
}
async fn generate_and_store_translation( async fn generate_and_store_translation(
client: &reqwest::Client, client: &reqwest::Client,
config: &AuthConfig, config: &AuthConfig,
post: &BlogPost, post: &BlogPost,
ollama_host: &str, ai_config: &AiConfig,
ai_did: &str,
) -> Result<()> { ) -> Result<()> {
// Generate translation // Generate translation using post content instead of just title
let translation = generate_ai_content(&post.title, "translate", ollama_host).await?; let content_to_translate = format!("Title: {}\n\n{}", post.title, post.contents);
let translation = generate_ai_content(&content_to_translate, "translate", ai_config).await?;
// Store in ai.syui.log.chat.lang collection // Get AI profile information
let ai_author = get_ai_profile(client, ai_config).await?;
// Extract post metadata
let post_slug = extract_slug_from_url(&post.href);
let post_date = extract_date_from_slug(&post_slug);
// Store in ai.syui.log.chat.lang collection with new format
let record_data = serde_json::json!({ let record_data = serde_json::json!({
"post_slug": extract_slug_from_url(&post.href), "$type": "ai.syui.log.chat.lang",
"post_title": post.title, "post": {
"post_url": post.href, "url": post.href,
"lang": "en", "slug": post_slug,
"content": translation, "title": post.title,
"generated_at": chrono::Utc::now().to_rfc3339(), "date": post_date,
"ai_did": ai_did "tags": post.tags,
"language": "ja"
},
"type": "en",
"text": translation,
"author": ai_author,
"createdAt": chrono::Utc::now().to_rfc3339()
}); });
store_atproto_record(client, config, &config.collections.chat_lang(), &record_data).await store_atproto_record(client, config, &config.collections.chat_lang(), &record_data).await
@@ -1148,20 +1777,39 @@ async fn generate_and_store_comment(
client: &reqwest::Client, client: &reqwest::Client,
config: &AuthConfig, config: &AuthConfig,
post: &BlogPost, post: &BlogPost,
ollama_host: &str, ai_config: &AiConfig,
ai_did: &str,
) -> Result<()> { ) -> Result<()> {
// Generate comment // Generate comment using limited post content for brevity
let comment = generate_ai_content(&post.title, "comment", ollama_host).await?; let limited_contents = if post.contents.len() > 300 {
format!("{}...", &post.contents[..300])
} else {
post.contents.clone()
};
let content_to_comment = format!("Title: {}\n\n{}", post.title, limited_contents);
let comment = generate_ai_content(&content_to_comment, "comment", ai_config).await?;
// Store in ai.syui.log.chat.comment collection // Get AI profile information
let ai_author = get_ai_profile(client, ai_config).await?;
// Extract post metadata
let post_slug = extract_slug_from_url(&post.href);
let post_date = extract_date_from_slug(&post_slug);
// Store in ai.syui.log.chat.comment collection with new format
let record_data = serde_json::json!({ let record_data = serde_json::json!({
"post_slug": extract_slug_from_url(&post.href), "$type": "ai.syui.log.chat.comment",
"post_title": post.title, "post": {
"post_url": post.href, "url": post.href,
"content": comment, "slug": post_slug,
"generated_at": chrono::Utc::now().to_rfc3339(), "title": post.title,
"ai_did": ai_did "date": post_date,
"tags": post.tags,
"language": "ja"
},
"type": "info",
"text": comment,
"author": ai_author,
"createdAt": chrono::Utc::now().to_rfc3339()
}); });
store_atproto_record(client, config, &config.collections.chat_comment(), &record_data).await store_atproto_record(client, config, &config.collections.chat_comment(), &record_data).await
@@ -1169,10 +1817,13 @@ async fn generate_and_store_comment(
async fn store_atproto_record( async fn store_atproto_record(
client: &reqwest::Client, client: &reqwest::Client,
config: &AuthConfig, _config: &AuthConfig,
collection: &str, collection: &str,
record_data: &serde_json::Value, record_data: &serde_json::Value,
) -> Result<()> { ) -> Result<()> {
// Always load fresh config to ensure we have valid tokens
let config = load_config_with_refresh().await?;
let url = format!("{}/xrpc/com.atproto.repo.putRecord", config.admin.pds); let url = format!("{}/xrpc/com.atproto.repo.putRecord", config.admin.pds);
let put_request = serde_json::json!({ let put_request = serde_json::json!({
@@ -1196,4 +1847,4 @@ async fn store_atproto_record(
} }
Ok(()) Ok(())
} }

View File

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

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)] #[derive(Subcommand)]
enum AuthCommands { enum AuthCommands {
/// Initialize OAuth authentication /// 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 /// Show current authentication status
Status, Status,
/// Logout and clear credentials /// Logout and clear credentials
@@ -122,6 +138,14 @@ enum StreamCommands {
#[arg(long)] #[arg(long)]
ai_generate: bool, 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 monitoring
Stop, Stop,
/// Show monitoring status /// Show monitoring status
@@ -183,8 +207,8 @@ async fn main() -> Result<()> {
} }
Commands::Auth { command } => { Commands::Auth { command } => {
match command { match command {
AuthCommands::Init => { AuthCommands::Init { pds, handle, password, access_jwt, refresh_jwt } => {
commands::auth::init().await?; commands::auth::init_with_options(pds, handle, password, access_jwt, refresh_jwt).await?;
} }
AuthCommands::Status => { AuthCommands::Status => {
commands::auth::status().await?; commands::auth::status().await?;
@@ -199,6 +223,9 @@ async fn main() -> Result<()> {
StreamCommands::Start { project_dir, daemon, ai_generate } => { StreamCommands::Start { project_dir, daemon, ai_generate } => {
commands::stream::start(project_dir, daemon, ai_generate).await?; 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 => { StreamCommands::Stop => {
commands::stream::stop().await?; commands::stream::stop().await?;
} }

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ use std::time::Instant;
use super::*; use super::*;
use crate::translator::markdown_parser::MarkdownParser; use crate::translator::markdown_parser::MarkdownParser;
#[derive(Clone)]
pub struct OllamaTranslator { pub struct OllamaTranslator {
client: Client, client: Client,
language_mapping: LanguageMapping, language_mapping: LanguageMapping,
@@ -129,86 +130,103 @@ Translation:"#,
} }
impl Translator for OllamaTranslator { impl Translator for OllamaTranslator {
async fn translate(&self, content: &str, config: &TranslationConfig) -> Result<String> { fn translate(&self, content: &str, config: &TranslationConfig) -> impl std::future::Future<Output = Result<String>> + Send {
let prompt = self.build_translation_prompt(content, config)?; async move {
self.call_ollama(&prompt, config).await 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> { fn translate_markdown(&self, content: &str, config: &TranslationConfig) -> impl std::future::Future<Output = Result<String>> + Send {
println!("🔄 Parsing markdown content..."); async move {
let sections = self.parser.parse_markdown(content)?; 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);
let translated_section = match &section { println!("📝 Found {} sections to process", sections.len());
MarkdownSection::Code(_content, _lang) => { let translated_sections = self.translate_sections(sections, config).await?;
if config.preserve_code {
println!(" ⏭️ Preserving code block"); println!("✅ Rebuilding markdown from translated sections...");
section // Preserve code blocks let result = self.parser.rebuild_markdown(translated_sections);
} else {
section // Still preserve for now Ok(result)
} }
} }
MarkdownSection::Link(text, url) => {
if config.preserve_links { fn translate_sections(&self, sections: Vec<MarkdownSection>, config: &TranslationConfig) -> impl std::future::Future<Output = Result<Vec<MarkdownSection>>> + Send {
println!(" ⏭️ Preserving link"); let config = config.clone();
section // Preserve links let client = self.client.clone();
} else { let parser = self.parser.clone();
// Translate link text only let language_mapping = self.language_mapping.clone();
let prompt = self.build_section_translation_prompt(&MarkdownSection::Text(text.clone()), config)?;
let translated_text = self.call_ollama(&prompt, config).await?; async move {
MarkdownSection::Link(translated_text.trim().to_string(), url.clone()) let translator = OllamaTranslator {
} client,
} language_mapping,
MarkdownSection::Image(_alt, _url) => { parser,
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,
}
}
}; };
translated_sections.push(translated_section); let mut translated_sections = Vec::new();
let start_time = Instant::now();
// Add small delay to avoid overwhelming Ollama
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; 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] [Service]
Type=simple Type=simple
User=syui User=syui
Group=syui
WorkingDirectory=/home/syui/git/log 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 Restart=always
RestartSec=5 RestartSec=5
StandardOutput=journal StandardOutput=journal
@@ -19,4 +19,4 @@ Environment=RUST_LOG=info
Environment=AILOG_DEBUG_ALL=1 Environment=AILOG_DEBUG_ALL=1
[Install] [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"