12 Commits

Author SHA1 Message Date
4edde5293a Add oauth_new: Complete OAuth authentication and comment system
- Created new oauth_new directory with clean OAuth implementation
- Added 4-tab interface: Lang, Comment, Collection, User Comments
- Implemented OAuth authentication with @atproto/oauth-client-browser
- Added comment posting functionality with putRecord
- Added proper PDS detection and error handling
- Skipped placeholder users to prevent errors
- Built comprehensive documentation (README.md, DEVELOPMENT.md)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-17 22:34:03 +09:00
f0fdf678c8 fix oauth plc 2025-06-17 17:43:03 +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
59 changed files with 3638 additions and 563 deletions

View File

@@ -51,7 +51,8 @@
"Bash(ailog:*)",
"WebFetch(domain:plc.directory)",
"WebFetch(domain:atproto.com)",
"WebFetch(domain:syu.is)"
"WebFetch(domain:syu.is)",
"Bash(sed:*)"
],
"deny": []
}

1
.gitignore vendored
View File

@@ -17,3 +17,4 @@ my-blog/templates/oauth-assets.html
cloudflared-config.yml
.config
oauth-server-example
atproto

View File

@@ -1,6 +1,6 @@
[package]
name = "ailog"
version = "0.1.8"
version = "0.2.1"
edition = "2021"
authors = ["syui"]
description = "A static blog generator with AI features"
@@ -10,6 +10,10 @@ license = "MIT"
name = "ailog"
path = "src/main.rs"
[lib]
name = "ailog"
path = "src/lib.rs"
[dependencies]
clap = { version = "4.5", features = ["derive"] }
pulldown-cmark = "0.11"
@@ -49,6 +53,7 @@ regex = "1.0"
tokio-tungstenite = { version = "0.21", features = ["rustls-tls-webpki-roots", "connect"], default-features = false }
futures-util = "0.3"
tungstenite = { version = "0.21", features = ["rustls-tls-webpki-roots"], default-features = false }
rpassword = "7.3"
[dev-dependencies]
tempfile = "3.14"

Binary file not shown.

208
claude.md
View File

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

View File

@@ -16,10 +16,12 @@ auto_translate = false
comment_moderation = false
ask_ai = true
provider = "ollama"
model = "gemma3:4b"
host = "https://ollama.syui.ai"
model = "qwen3"
model_translation = "llama3.2:1b"
model_technical = "phi3:mini"
host = "http://localhost:11434"
system_prompt = "あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"
ai_handle = "ai.syui.ai"
handle = "ai.syui.ai"
#num_predict = 200
[oauth]
@@ -27,5 +29,5 @@ json = "client-metadata.json"
redirect = "oauth/callback"
admin = "ai.syui.ai"
collection = "ai.syui.log"
pds = "syu.is" # Network configuration: "bsky.social" for Bluesky, "syu.is" for independent network
handle_list = ["syui.syui.ai", "yui.syui.ai", "ai.syui.ai", "syui.syu.is", "ai.syu.is", "ai.ai"]
pds = "syu.is"
handle_list = ["syui.syui.ai", "ai.syui.ai", "ai.ai"]

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_name": "ai.card",
"client_name": "ai.log",
"client_uri": "https://syui.ai",
"logo_uri": "https://syui.ai/favicon.ico",
"tos_uri": "https://syui.ai/terms",
@@ -21,4 +21,4 @@
"subject_type": "public",
"application_type": "web",
"dpop_bound_access_tokens": true
}
}

View File

@@ -9,13 +9,13 @@ 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","yui.syui.ai","syui.syu.is","ai.syu.is"]
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_MODEL=gemma3:1b
VITE_AI_HOST=https://ollama.syui.ai
VITE_AI_SYSTEM_PROMPT="あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ import { authService, User } from './services/auth';
import { atprotoOAuthService } from './services/atproto-oauth';
import { appConfig, getCollectionNames } from './config/app';
import { getProfileForUser, detectPdsFromHandle, getApiUrlForUser, verifyPdsDetection, getNetworkConfigFromPdsEndpoint, getNetworkConfig } from './utils/pds-detection';
import { isValidDid } from './utils/validation';
import './App.css';
function App() {
@@ -31,7 +32,33 @@ function App() {
const [aiCommentRecords, setAiCommentRecords] = useState<any[]>([]);
const [aiProfile, setAiProfile] = useState<any>(null);
const [adminDid, setAdminDid] = useState<string | null>(null);
const [aiDid, setAiDid] = useState<string | null>(null);
// ハンドルからDIDを解決する関数
const resolveHandleToDid = async (handle: string): Promise<string | null> => {
try {
const profile = await import('./utils/pds-detection').then(m => m.getProfileForUser(handle));
return profile?.did || null;
} catch {
return null;
}
};
useEffect(() => {
// 管理者とAIのDIDを解決
const resolveAdminAndAiDids = async () => {
const [resolvedAdminDid, resolvedAiDid] = await Promise.all([
resolveHandleToDid(appConfig.adminHandle),
resolveHandleToDid(appConfig.aiHandle)
]);
setAdminDid(resolvedAdminDid || appConfig.adminDid);
setAiDid(resolvedAiDid || appConfig.aiDid);
};
resolveAdminAndAiDids();
// Setup Jetstream WebSocket for real-time comments (optional)
const setupJetstream = () => {
try {
@@ -83,13 +110,31 @@ function App() {
return false;
};
// キャッシュがなければ、ATProtoから取得認証状態に関係なく
if (!loadCachedComments()) {
loadAllComments(); // URLフィルタリングを無効にして全コメント表示
}
// DID解決が完了してからコメントとチャット履歴を読み込む
const loadDataAfterDidResolution = () => {
// キャッシュがなければ、ATProtoから取得認証状態に関係なく
if (!loadCachedComments()) {
loadAllComments(); // URLフィルタリングを無効にして全コメント表示
}
// Load AI chat history (認証状態に関係なく、全ユーザーのチャット履歴を表示)
loadAiChatHistory();
// Load AI generated content (lang:en and AI comments)
loadAIGeneratedContent();
};
// Load AI chat history (認証状態に関係なく、全ユーザーのチャット履歴を表示)
loadAiChatHistory();
// Wait for DID resolution before loading data
if (adminDid && aiDid) {
loadDataAfterDidResolution();
} else {
// Wait a bit and try again
setTimeout(() => {
if (adminDid && aiDid) {
loadDataAfterDidResolution();
}
}, 1000);
}
// Load AI profile from handle
const loadAiProfile = async () => {
@@ -113,6 +158,7 @@ function App() {
const config = getNetworkConfigFromPdsEndpoint(`https://${pds}`);
const apiEndpoint = config.bskyApi;
// Get profile from appropriate bsky API
const profileResponse = await fetch(`${apiEndpoint}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`);
if (profileResponse.ok) {
@@ -135,7 +181,7 @@ function App() {
});
}
} catch (err) {
console.error('Failed to load AI profile:', err);
// Failed to load AI profile
// Fallback to config values
setAiProfile({
did: appConfig.aiDid,
@@ -178,13 +224,7 @@ function App() {
// Ensure handle is not DID
const handle = oauthResult.handle !== oauthResult.did ? oauthResult.handle : oauthResult.handle;
// Check if handle is allowed
if (appConfig.allowedHandles.length > 0 && !appConfig.allowedHandles.includes(handle)) {
console.warn(`Handle ${handle} is not in allowed list:`, appConfig.allowedHandles);
setError(`Access denied: ${handle} is not authorized for this application.`);
setIsLoading(false);
return;
}
// Note: appConfig.allowedHandles is used for PDS detection, not access control
// Get user profile including avatar
const userProfile = await getUserProfile(oauthResult.did, handle);
@@ -198,7 +238,7 @@ function App() {
loadAiChatHistory();
// Load user list records if admin
if (userProfile.did === appConfig.adminDid) {
if (userProfile.did === adminDid) {
loadUserListRecords();
}
@@ -211,7 +251,7 @@ function App() {
if (verifiedUser) {
// Check if handle is allowed
if (appConfig.allowedHandles.length > 0 && !appConfig.allowedHandles.includes(verifiedUser.handle)) {
console.warn(`Handle ${verifiedUser.handle} is not in allowed list:`, appConfig.allowedHandles);
// Handle not in allowed list
setError(`Access denied: ${verifiedUser.handle} is not authorized for this application.`);
setIsLoading(false);
return;
@@ -224,7 +264,7 @@ function App() {
loadAllComments();
// Load user list records if admin
if (verifiedUser.did === appConfig.adminDid) {
if (verifiedUser.did === adminDid) {
loadUserListRecords();
}
}
@@ -244,6 +284,15 @@ function App() {
};
}, []);
// DID解決完了時にデータを再読み込み
useEffect(() => {
if (adminDid && aiDid) {
loadAllComments();
loadAiChatHistory();
loadAIGeneratedContent();
}
}, [adminDid, aiDid]);
const getUserProfile = async (did: string, handle: string): Promise<User> => {
try {
const agent = atprotoOAuthService.getAgent();
@@ -281,21 +330,28 @@ function App() {
const loadAiChatHistory = async () => {
try {
// Load all chat records from users in admin's user list
const adminDid = appConfig.adminDid;
const atprotoApi = appConfig.atprotoApi || 'https://bsky.social';
const collections = getCollectionNames(appConfig.collections.base);
const currentAdminDid = adminDid || appConfig.adminDid;
// First, get user list from admin using their proper PDS
// Don't proceed if we don't have a valid DID
if (!currentAdminDid || !isValidDid(currentAdminDid)) {
return;
}
// Resolve admin's actual PDS from their DID
let adminPdsEndpoint;
try {
const resolved = await import('./utils/pds-detection').then(m => m.resolvePdsFromRepo(adminDid));
const resolved = await import('./utils/pds-detection').then(m => m.resolvePdsFromRepo(currentAdminDid));
const config = await import('./utils/pds-detection').then(m => m.getNetworkConfigFromPdsEndpoint(resolved.pds));
adminPdsEndpoint = config.pdsApi;
} catch {
adminPdsEndpoint = atprotoApi;
// Fallback to configured PDS
const adminConfig = getNetworkConfig(appConfig.atprotoPds);
adminPdsEndpoint = adminConfig.pdsApi;
}
const userListResponse = await fetch(`${adminPdsEndpoint}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(adminDid)}&collection=${encodeURIComponent(collections.user)}&limit=100`);
const collections = getCollectionNames(appConfig.collections.base);
const userListResponse = await fetch(`${adminPdsEndpoint}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(currentAdminDid)}&collection=${encodeURIComponent(collections.user)}&limit=100`);
if (!userListResponse.ok) {
setAiChatHistory([]);
@@ -318,7 +374,7 @@ function App() {
});
// Always include admin DID to check admin's own chats
allUserDids.push(adminDid);
allUserDids.push(currentAdminDid);
const userDids = [...new Set(allUserDids)];
@@ -329,6 +385,10 @@ function App() {
// Use per-user PDS detection for each user's chat records
let userPdsEndpoint;
try {
// Validate DID format before making API calls
if (!userDid || !userDid.startsWith('did:')) {
continue;
}
const resolved = await import('./utils/pds-detection').then(m => m.resolvePdsFromRepo(userDid));
const config = await import('./utils/pds-detection').then(m => m.getNetworkConfigFromPdsEndpoint(resolved.pds));
userPdsEndpoint = config.pdsApi;
@@ -342,6 +402,9 @@ function App() {
const chatData = await chatResponse.json();
const records = chatData.records || [];
allChatRecords.push(...records);
} else if (chatResponse.status === 400) {
// Skip 400 errors (repo not found, etc)
continue;
}
} catch (err) {
continue;
@@ -386,12 +449,28 @@ function App() {
// Load AI generated content from admin DID
const loadAIGeneratedContent = async () => {
try {
const adminDid = appConfig.adminDid;
const atprotoApi = appConfig.atprotoApi || 'https://bsky.social';
const currentAdminDid = adminDid || appConfig.adminDid;
// Don't proceed if we don't have a valid DID
if (!currentAdminDid || !isValidDid(currentAdminDid)) {
return;
}
// Resolve admin's actual PDS from their DID
let atprotoApi;
try {
const resolved = await import('./utils/pds-detection').then(m => m.resolvePdsFromRepo(currentAdminDid));
const config = await import('./utils/pds-detection').then(m => m.getNetworkConfigFromPdsEndpoint(resolved.pds));
atprotoApi = config.pdsApi;
} catch {
// Fallback to configured PDS
const adminConfig = getNetworkConfig(appConfig.atprotoPds);
atprotoApi = adminConfig.pdsApi;
}
const collections = getCollectionNames(appConfig.collections.base);
// Load lang:en records
const langResponse = await fetch(`${atprotoApi}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(adminDid)}&collection=${encodeURIComponent(collections.chatLang)}&limit=100`);
const langResponse = await fetch(`${atprotoApi}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(currentAdminDid)}&collection=${encodeURIComponent(collections.chatLang)}&limit=100`);
if (langResponse.ok) {
const langData = await langResponse.json();
const langRecords = langData.records || [];
@@ -408,8 +487,14 @@ function App() {
setLangEnRecords(filteredLangRecords);
}
// Load AI comment records
const commentResponse = await fetch(`${atprotoApi}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(adminDid)}&collection=${encodeURIComponent(collections.chatComment)}&limit=100`);
// Load AI comment records from admin account (not AI account)
if (!currentAdminDid) {
console.warn('No Admin DID available, skipping AI comment loading');
setAiCommentRecords([]);
return;
}
const commentResponse = await fetch(`${atprotoApi}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(currentAdminDid)}&collection=${encodeURIComponent(collections.chatComment)}&limit=100`);
if (commentResponse.ok) {
const commentData = await commentResponse.json();
const commentRecords = commentData.records || [];
@@ -459,6 +544,13 @@ function App() {
const profileResponse = await fetch(`${apiEndpoint}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`);
if (profileResponse.ok) {
const profileData = await profileResponse.json();
// Determine correct web URL based on avatar source
let webUrl = config.webUrl; // Default from handle-based detection
if (profileData.avatar && profileData.avatar.includes('cdn.bsky.app')) {
webUrl = 'https://bsky.app'; // Override to Bluesky if avatar is from Bluesky
}
return {
...record,
value: {
@@ -468,7 +560,7 @@ function App() {
avatar: profileData.avatar,
displayName: profileData.displayName || handle,
_pdsEndpoint: `https://${pds}`, // Store PDS info for later use
_webUrl: config.webUrl, // Store web URL for profile links
_webUrl: webUrl, // Store corrected web URL for profile links
}
}
};
@@ -505,32 +597,33 @@ function App() {
const loadUsersFromRecord = async () => {
try {
// 管理者のユーザーリストを取得 using proper PDS detection
const adminDid = appConfig.adminDid;
const currentAdminDid = adminDid || appConfig.adminDid;
// Use per-user PDS detection for admin's records
let adminPdsEndpoint;
try {
const resolved = await import('./utils/pds-detection').then(m => m.resolvePdsFromRepo(adminDid));
const resolved = await import('./utils/pds-detection').then(m => m.resolvePdsFromRepo(currentAdminDid));
const config = await import('./utils/pds-detection').then(m => m.getNetworkConfigFromPdsEndpoint(resolved.pds));
adminPdsEndpoint = config.pdsApi;
} catch {
adminPdsEndpoint = 'https://bsky.social'; // Fallback
}
const response = await fetch(`${adminPdsEndpoint}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(adminDid)}&collection=${encodeURIComponent(getCollectionNames(appConfig.collections.base).user)}&limit=100`);
const userCollectionUrl = `${adminPdsEndpoint}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(currentAdminDid)}&collection=${encodeURIComponent(getCollectionNames(appConfig.collections.base).user)}&limit=100`;
const response = await fetch(userCollectionUrl);
if (!response.ok) {
// Failed to fetch user list from admin, using default users
return getDefaultUsers();
}
const data = await response.json();
const userRecords = data.records || [];
// User records found
if (userRecords.length === 0) {
// No user records found, using default users
return getDefaultUsers();
const defaultUsers = getDefaultUsers();
return defaultUsers;
}
// レコードからユーザーリストを構築し、プレースホルダーDIDを実際のDIDに解決
@@ -562,7 +655,6 @@ function App() {
}
}
// Loaded and resolved users from admin records
return allUsers;
} catch (err) {
// Failed to load users from records, using defaults
@@ -574,19 +666,19 @@ function App() {
const loadUserListRecords = async () => {
try {
// Loading user list records using proper PDS detection
const adminDid = appConfig.adminDid;
const currentAdminDid = adminDid || appConfig.adminDid;
// Use per-user PDS detection for admin's records
let adminPdsEndpoint;
try {
const resolved = await import('./utils/pds-detection').then(m => m.resolvePdsFromRepo(adminDid));
const resolved = await import('./utils/pds-detection').then(m => m.resolvePdsFromRepo(currentAdminDid));
const config = await import('./utils/pds-detection').then(m => m.getNetworkConfigFromPdsEndpoint(resolved.pds));
adminPdsEndpoint = config.pdsApi;
} catch {
adminPdsEndpoint = 'https://bsky.social'; // Fallback
}
const response = await fetch(`${adminPdsEndpoint}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(adminDid)}&collection=${encodeURIComponent(getCollectionNames(appConfig.collections.base).user)}&limit=100`);
const response = await fetch(`${adminPdsEndpoint}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(currentAdminDid)}&collection=${encodeURIComponent(getCollectionNames(appConfig.collections.base).user)}&limit=100`);
if (!response.ok) {
// Failed to fetch user list records
@@ -611,21 +703,26 @@ function App() {
};
const getDefaultUsers = () => {
const currentAdminDid = adminDid || appConfig.adminDid;
const defaultUsers = [
// Default admin user
{ did: appConfig.adminDid, handle: 'syui.ai', pds: 'https://bsky.social' },
{ did: currentAdminDid, handle: appConfig.adminHandle, pds: 'https://syu.is' },
];
// 現在ログインしているユーザーも追加(重複チェック)
if (user && user.did && user.handle && !defaultUsers.find(u => u.did === user.did)) {
// Detect PDS based on handle
const userPds = user.handle.endsWith('.syu.is') ? 'https://syu.is' :
user.handle.endsWith('.syui.ai') ? 'https://syu.is' :
'https://bsky.social';
defaultUsers.push({
did: user.did,
handle: user.handle,
pds: user.handle.endsWith('.syu.is') ? 'https://syu.is' : 'https://bsky.social'
pds: userPds
});
}
// Default users list (including current user)
return defaultUsers;
};
@@ -714,6 +811,14 @@ function App() {
const profile = await import('./utils/pds-detection').then(m => m.getProfileForUser(record.value.author.handle));
if (profile) {
// Determine network config based on profile data
let webUrl = 'https://bsky.app'; // Default to Bluesky
if (profile.avatar && profile.avatar.includes('cdn.bsky.app')) {
webUrl = 'https://bsky.app';
} else if (profile.avatar && profile.avatar.includes('bsky.syu.is')) {
webUrl = 'https://web.syu.is';
}
return {
...record,
value: {
@@ -722,6 +827,7 @@ function App() {
...record.value.author,
avatar: profile.avatar,
displayName: profile.displayName || record.value.author.handle,
_webUrl: webUrl, // Store network config for profile URL generation
}
}
};
@@ -888,7 +994,7 @@ function App() {
// 管理者チェック
const isAdmin = (user: User | null): boolean => {
return user?.did === appConfig.adminDid;
return user?.did === adminDid || user?.did === appConfig.adminDid;
};
// ユーザーリスト投稿
@@ -1038,14 +1144,34 @@ function App() {
// ユーザーハンドルからプロフィールURLを生成
const generateProfileUrl = (author: any): string => {
// Use stored PDS info if available (from comment enhancement)
// Check if this is admin/AI handle that should use configured PDS
if (author.handle === appConfig.adminHandle || author.handle === appConfig.aiHandle) {
const config = getNetworkConfig(appConfig.atprotoPds);
return `${config.webUrl}/profile/${author.did}`;
}
// For ai.syu.is handle, also use configured PDS
if (author.handle === 'ai.syu.is') {
const config = getNetworkConfig(appConfig.atprotoPds);
return `${config.webUrl}/profile/${author.did}`;
}
// For other users, detect network based on avatar URL or stored network info
if (author.avatar && author.avatar.includes('cdn.bsky.app')) {
// User has Bluesky avatar, use Bluesky web interface
return `https://bsky.app/profile/${author.did}`;
}
// Check if we have stored network config from profile fetching
if (author._webUrl) {
return `${author._webUrl}/profile/${author.did}`;
}
// Fallback to handle-based detection
// Fallback: Get PDS from handle for other users
const pds = detectPdsFromHandle(author.handle);
const config = getNetworkConfigFromPdsEndpoint(`https://${pds}`);
const config = getNetworkConfig(pds);
// Use DID for profile URL
return `${config.webUrl}/profile/${author.did}`;
};
@@ -1081,7 +1207,9 @@ function App() {
// Extract content based on format
const contentText = isNewFormat ? value.text : (value.content || value.body || '');
const authorInfo = isNewFormat ? value.author : null;
// Use the author from the record if available, otherwise fall back to AI profile
const authorInfo = value.author || aiProfile;
const postInfo = isNewFormat ? value.post : null;
const contentType = value.type || 'unknown';
const createdAt = value.createdAt || value.generated_at || '';
@@ -1093,29 +1221,22 @@ function App() {
src={authorInfo?.avatar || generatePlaceholderAvatar('AI')}
alt="AI Avatar"
className="comment-avatar"
ref={(img) => {
// For old format, try to fetch from ai_did
if (img && !isNewFormat && value.ai_did) {
fetch(`${appConfig.bskyPublicApi}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(value.ai_did)}`)
.then(res => res.json())
.then(data => {
if (data.avatar && img) {
img.src = data.avatar;
}
})
.catch(err => {
// Keep placeholder on error
});
}
}}
/>
<div className="comment-author-info">
<span className="comment-author">
{authorInfo?.displayName || 'AI'}
</span>
<span className="comment-handle">
@{authorInfo?.handle || aiProfile?.handle || 'yui.syui.ai'}
{authorInfo?.displayName || 'ai'}
</span>
<a
href={generateProfileUrl({
handle: authorInfo?.handle || aiProfile?.handle || appConfig.aiHandle,
did: authorInfo?.did || aiProfile?.did || appConfig.aiDid
})}
target="_blank"
rel="noopener noreferrer"
className="comment-handle"
>
@{authorInfo?.handle || aiProfile?.handle || appConfig.aiHandle}
</a>
</div>
<span className="comment-date">
{new Date(createdAt).toLocaleString()}
@@ -1242,7 +1363,7 @@ function App() {
name="userList"
value={userListInput}
onChange={(e) => setUserListInput(e.target.value)}
placeholder="ユーザーハンドルをカンマ区切りで入力&#10;例: syui.ai, yui.syui.ai, user.bsky.social"
placeholder="ユーザーハンドルをカンマ区切りで入力&#10;例: syui.ai, ai.syui.ai, user.bsky.social"
rows={3}
disabled={isPostingUserList}
/>
@@ -1439,93 +1560,9 @@ function App() {
{aiChatHistory.length === 0 ? (
<p className="no-chat">No AI conversations yet. Start chatting with Ask AI!</p>
) : (
aiChatHistory.map((record, index) => {
// For AI responses, use AI DID; for user questions, use the actual author
const isAiResponse = record.value.type === 'answer';
const displayDid = isAiResponse ? appConfig.aiDid : record.value.author?.did;
const displayHandle = isAiResponse ? (aiProfile?.handle || 'yui.syui.ai') : record.value.author?.handle;
const displayName = isAiResponse ? 'AI' : (record.value.author?.displayName || record.value.author?.handle);
return (
<div key={index} className="comment-item">
<div className="comment-header">
<img
src={generatePlaceholderAvatar(displayHandle || 'unknown')}
alt={isAiResponse ? "AI Avatar" : "User Avatar"}
className="comment-avatar"
ref={(img) => {
// Fetch fresh avatar from API when component mounts
if (img && displayDid) {
fetch(`${appConfig.bskyPublicApi}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(displayDid)}`)
.then(res => res.json())
.then(data => {
if (data.avatar && img) {
img.src = data.avatar;
}
})
.catch(err => {
// Keep placeholder on error
});
}
}}
/>
<div className="comment-author-info">
<span className="comment-author">
{displayName || 'unknown'}
</span>
<a
href={generateProfileUrl({ handle: displayHandle, did: displayDid })}
target="_blank"
rel="noopener noreferrer"
className="comment-handle"
>
@{displayHandle || 'unknown'}
</a>
</div>
<span className="comment-date">
{new Date(record.value.createdAt).toLocaleString()}
</span>
<div className="comment-actions">
<button
onClick={() => toggleJsonDisplay(record.uri)}
className="json-button"
title="Show/Hide JSON"
>
{showJsonFor === record.uri ? 'Hide' : 'JSON'}
</button>
<button className="chat-type-button">
{record.value.type === 'question' ? 'Question' : 'Answer'}
</button>
</div>
</div>
<div className="comment-meta">
{record.value.post?.url && (
<small><a href={record.value.post.url}>{record.value.post.url}</a></small>
)}
</div>
{/* JSON Display */}
{showJsonFor === record.uri && (
<div className="json-display">
<h5>JSON Record:</h5>
<pre className="json-content">
{JSON.stringify(record, null, 2)}
</pre>
</div>
)}
<div className="comment-content">
{record.value.text?.split('\n').map((line: string, index: number) => (
<React.Fragment key={index}>
{line}
{index < record.value.text.split('\n').length - 1 && <br />}
</React.Fragment>
))}
</div>
</div>
);
})
aiChatHistory.map((record, index) =>
renderAIContent(record, index, 'comment-item')
)
)}
</div>
)}
@@ -1534,7 +1571,7 @@ function App() {
{activeTab === 'lang-en' && (
<div className="comments-list">
{langEnRecords.length === 0 ? (
<p className="no-content">No English translations yet</p>
<p className="no-content">No EN translations yet</p>
) : (
langEnRecords.map((record, index) =>
renderAIContent(record, index, 'lang-item')
@@ -1549,77 +1586,9 @@ function App() {
{aiCommentRecords.length === 0 ? (
<p className="no-content">No AI comments yet</p>
) : (
aiCommentRecords.map((record, index) => (
<div key={index} className="comment-item">
<div className="comment-header">
<img
src={generatePlaceholderAvatar('ai')}
alt="AI Avatar"
className="comment-avatar"
ref={(img) => {
// Fetch AI avatar
if (img && appConfig.aiDid) {
fetch(`${appConfig.bskyPublicApi}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(appConfig.aiDid)}`)
.then(res => res.json())
.then(data => {
if (data.avatar && img) {
img.src = data.avatar;
}
})
.catch(err => {
// Keep placeholder on error
});
}
}}
/>
<div className="comment-author-info">
<span className="comment-author">
AI
</span>
<span className="comment-handle">
@{aiProfile?.handle || 'yui.syui.ai'}
</span>
</div>
<span className="comment-date">
{new Date(record.value.createdAt || record.value.generated_at).toLocaleString()}
</span>
<div className="comment-actions">
<button
onClick={() => toggleJsonDisplay(record.uri)}
className="json-button"
title="Show/Hide JSON"
>
{showJsonFor === record.uri ? 'Hide' : 'JSON'}
</button>
</div>
</div>
<div className="comment-meta">
{(record.value.post?.url || record.value.post_url) && (
<small><a href={record.value.post?.url || record.value.post_url}>{record.value.post?.url || record.value.post_url}</a></small>
)}
</div>
{/* JSON Display */}
{showJsonFor === record.uri && (
<div className="json-display">
<h5>JSON Record:</h5>
<pre className="json-content">
{JSON.stringify(record, null, 2)}
</pre>
</div>
)}
<div className="comment-content">
{(record.value.text || record.value.comment)?.split('\n').map((line: string, index: number) => (
<React.Fragment key={index}>
{line}
{index < (record.value.text || record.value.comment)?.split('\n').length - 1 && <br />}
</React.Fragment>
))}
</div>
</div>
))
aiCommentRecords.map((record, index) =>
renderAIContent(record, index, 'comment-item')
)
)}
</div>
)}

View File

@@ -191,6 +191,7 @@ Answer:`;
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Origin': 'https://syui.ai',
},
body: JSON.stringify({
model: aiConfig.model,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -84,10 +84,12 @@ function extractRkeyFromUrl(): string | undefined {
// Get application configuration from environment variables
export function getAppConfig(): AppConfig {
const host = import.meta.env.VITE_APP_HOST || 'https://log.syui.ai';
const adminHandle = import.meta.env.VITE_ADMIN_HANDLE || 'ai.syui.ai';
const aiHandle = import.meta.env.VITE_AI_HANDLE || 'ai.syui.ai';
// DIDsはハンドルから実行時に解決されるフォールバック用のみ保持
const adminDid = import.meta.env.VITE_ADMIN_DID || 'did:plc:uqzpqmrjnptsxezjx4xuh2mn';
const adminHandle = import.meta.env.VITE_ADMIN_HANDLE || 'syui.ai';
const aiDid = import.meta.env.VITE_AI_DID || 'did:plc:4hqjfn7m6n5hno3doamuhgef';
const aiHandle = import.meta.env.VITE_AI_HANDLE || 'yui.syui.ai';
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 || '';
@@ -111,7 +113,7 @@ export function getAppConfig(): AppConfig {
const aiEnabled = import.meta.env.VITE_AI_ENABLED === 'true';
const aiAskAi = import.meta.env.VITE_AI_ASK_AI === 'true';
const aiProvider = import.meta.env.VITE_AI_PROVIDER || 'ollama';
const aiModel = import.meta.env.VITE_AI_MODEL || 'gemma2:2b';
const aiModel = import.meta.env.VITE_AI_MODEL || 'gemma3:4b';
const aiHost = import.meta.env.VITE_AI_HOST || 'https://ollama.syui.ai';
const aiSystemPrompt = import.meta.env.VITE_AI_SYSTEM_PROMPT || 'You are a helpful AI assistant trained on this blog\'s content.';
const atprotoPds = import.meta.env.VITE_ATPROTO_PDS || 'syu.is';

View File

@@ -12,6 +12,7 @@ interface AtprotoSession {
class AtprotoOAuthService {
private oauthClient: BrowserOAuthClient | null = null;
private oauthClientSyuIs: BrowserOAuthClient | null = null;
private agent: Agent | null = null;
private initializePromise: Promise<void> | null = null;
@@ -31,22 +32,27 @@ class AtprotoOAuthService {
private async _doInitialize(): Promise<void> {
try {
// Generate client ID based on current origin
const clientId = this.getClientId();
// Support multiple PDS hosts for OAuth
// Initialize both OAuth clients
this.oauthClient = await BrowserOAuthClient.load({
clientId: clientId,
handleResolver: 'https://bsky.social', // Default resolver
handleResolver: 'https://bsky.social',
plcDirectoryUrl: 'https://plc.directory',
});
this.oauthClientSyuIs = await BrowserOAuthClient.load({
clientId: clientId,
handleResolver: 'https://syu.is',
plcDirectoryUrl: 'https://plc.syu.is',
});
// Try to restore existing session
const result = await this.oauthClient.init();
// Try to restore existing session from either client
let result = await this.oauthClient.init();
if (!result?.session) {
result = await this.oauthClientSyuIs.init();
}
if (result?.session) {
// Create Agent instance with proper configuration
@@ -92,41 +98,13 @@ class AtprotoOAuthService {
}
private async processSession(session: any): Promise<{ did: string; handle: string }> {
// Log full session structure
// Check if agent has properties we can access
if (session.agent) {
}
const did = session.sub || session.did;
let handle = session.handle || 'unknown';
// Create Agent directly with session (per official docs)
try {
this.agent = new Agent(session);
// Check if agent has session info after creation
if (this.agent.session) {
}
} catch (err) {
// Fallback to dpopFetch method
this.agent = new Agent({
service: session.server?.serviceEndpoint || 'https://bsky.social',
@@ -204,39 +182,15 @@ class AtprotoOAuthService {
return `${origin}/client-metadata.json`;
}
private detectPDSFromHandle(handle: string): string {
// Supported PDS hosts and their corresponding handles
const pdsMapping = {
'syu.is': 'https://syu.is',
'bsky.social': 'https://bsky.social',
};
// Check if handle ends with known PDS domains
for (const [domain, pdsUrl] of Object.entries(pdsMapping)) {
if (handle.endsWith(`.${domain}`)) {
return pdsUrl;
}
}
// Default to bsky.social
return 'https://bsky.social';
}
async initiateOAuthFlow(handle?: string): Promise<void> {
try {
if (!this.oauthClient) {
if (!this.oauthClient || !this.oauthClientSyuIs) {
await this.initialize();
}
if (!this.oauthClient) {
throw new Error('Failed to initialize OAuth client');
if (!this.oauthClient || !this.oauthClientSyuIs) {
throw new Error('Failed to initialize OAuth clients');
}
// If handle is not provided, prompt user
@@ -247,49 +201,27 @@ class AtprotoOAuthService {
}
}
// Detect PDS based on handle
const pdsUrl = this.detectPDSFromHandle(handle);
// Re-initialize OAuth client with correct PDS if needed
if (pdsUrl !== 'https://bsky.social') {
this.oauthClient = await BrowserOAuthClient.load({
clientId: this.getClientId(),
handleResolver: pdsUrl,
});
}
// Start OAuth authorization flow
// Determine which OAuth client to use
const allowedHandlesStr = import.meta.env.VITE_ATPROTO_HANDLE_LIST || '[]';
let allowedHandles: string[] = [];
try {
const authUrl = await this.oauthClient.authorize(handle, {
scope: 'atproto transition:generic',
});
// 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
window.location.href = authUrl.toString();
} catch (authorizeError) {
throw authorizeError;
allowedHandles = JSON.parse(allowedHandlesStr);
} catch {
allowedHandles = [];
}
const usesSyuIs = handle.endsWith('.syu.is') || allowedHandles.includes(handle);
const oauthClient = usesSyuIs ? this.oauthClientSyuIs : this.oauthClient;
// Start OAuth authorization flow
const authUrl = await oauthClient.authorize(handle, {
scope: 'atproto transition:generic',
});
// Redirect to authorization server
window.location.href = authUrl.toString();
} catch (error) {
throw new Error(`OAuth認証の開始に失敗しました: ${error}`);
}
}
@@ -345,21 +277,15 @@ class AtprotoOAuthService {
async checkSession(): Promise<{ did: string; handle: string } | null> {
try {
if (!this.oauthClient) {
await this.initialize();
}
if (!this.oauthClient) {
return null;
}
const result = await this.oauthClient.init();
if (result?.session) {
// Use the common session processing method
@@ -424,28 +350,20 @@ class AtprotoOAuthService {
async logout(): Promise<void> {
try {
// Clear Agent
this.agent = null;
// Clear BrowserOAuthClient session
if (this.oauthClient) {
try {
// BrowserOAuthClient may have a revoke or signOut method
if (typeof (this.oauthClient as any).signOut === 'function') {
await (this.oauthClient as any).signOut();
} else if (typeof (this.oauthClient as any).revoke === 'function') {
await (this.oauthClient as any).revoke();
} else {
}
} catch (oauthError) {
// Ignore logout errors
}
// Reset the OAuth client to force re-initialization
@@ -457,18 +375,16 @@ class AtprotoOAuthService {
localStorage.removeItem('atproto_session');
sessionStorage.clear();
// Clear all localStorage items that might be related to OAuth
const keysToRemove: string[] = [];
// Clear all OAuth-related storage
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && (key.includes('oauth') || key.includes('atproto') || key.includes('session'))) {
keysToRemove.push(key);
localStorage.removeItem(key);
}
}
keysToRemove.forEach(key => {
localStorage.removeItem(key);
});
// Clear internal session info
(this as any)._sessionInfo = null;

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

@@ -1,5 +1,7 @@
// PDS Detection and API URL mapping utilities
import { isValidDid, isValidHandle } from './validation';
export interface NetworkConfig {
pdsApi: string;
plcApi: string;
@@ -9,12 +11,33 @@ export interface NetworkConfig {
// Detect PDS from handle
export function detectPdsFromHandle(handle: string): string {
if (handle.endsWith('.syu.is')) {
// 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';
}
@@ -74,8 +97,13 @@ export function getApiUrlForUser(handle: string): string {
return config.bskyApi;
}
// Resolve handle/DID to actual PDS endpoint using com.atproto.repo.describeRepo
// 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;
@@ -83,7 +111,7 @@ export async function resolvePdsFromRepo(handleOrDid: string): Promise<{ pds: st
if (!handleOrDid.startsWith('did:')) {
try {
// Try multiple endpoints for handle resolution
const resolveEndpoints = ['https://public.api.bsky.app', 'https://bsky.syu.is'];
const resolveEndpoints = ['https://public.api.bsky.app', 'https://bsky.syu.is', 'https://syu.is'];
let resolved = false;
for (const endpoint of resolveEndpoints) {
@@ -108,7 +136,34 @@ export async function resolvePdsFromRepo(handleOrDid: string): Promise<{ pds: st
}
}
// Now use com.atproto.repo.describeRepo to get PDS from known PDS endpoints
// 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) {
@@ -168,7 +223,7 @@ export async function resolveHandleToDid(handle: string): Promise<{ did: string;
pds: actualPds
};
} catch (error) {
console.error(`Failed to resolve handle ${handle}:`, error);
// Failed to resolve handle
// Fallback to handle-based detection
const fallbackPds = detectPdsFromHandle(handle);
@@ -204,7 +259,7 @@ export async function getProfileForUser(handleOrDid: string, knownPdsEndpoint?:
return await response.json();
} catch (error) {
console.error(`Failed to get profile for ${handleOrDid}:`, error);
// Failed to get profile
// Final fallback: try with default Bluesky API
try {

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);
}

6
oauth_new/.env Normal file
View File

@@ -0,0 +1,6 @@
VITE_ADMIN=ai.syui.ai
VITE_PDS=syu.is
VITE_HANDLE_LIST=["ai.syui.ai", "syui.syui.ai", "ai.ai"]
VITE_COLLECTION=ai.syui.log
VITE_OAUTH_CLIENT_ID=https://syui.ai/client-metadata.json
VITE_OAUTH_REDIRECT_URI=https://syui.ai/oauth/callback

334
oauth_new/DEVELOPMENT.md Normal file
View File

@@ -0,0 +1,334 @@
# 開発ガイド
## 設計思想
このプロジェクトは以下の原則に基づいて設計されています:
### 1. 環境変数による設定の外部化
- ハードコードを避け、設定は全て環境変数で管理
- `src/config/env.js` で一元管理
### 2. PDSPersonal Data Serverの自動判定
- `VITE_HANDLE_LIST``VITE_PDS` による自動判定
- syu.is系とbsky.social系の自動振り分け
### 3. コンポーネントの責任分離
- Hooks: ビジネスロジック
- Components: UI表示のみ
- Services: 外部API連携
- Utils: 純粋関数
## アーキテクチャ詳細
### データフロー
```
User Input
Hooks (useAuth, useAdminData, usePageContext)
Services (OAuthService)
API (atproto.js)
ATProto Network
Components (UI Display)
```
### 状態管理
React Hooksによる状態管理
- `useAuth`: OAuth認証状態
- `useAdminData`: 管理者データ(プロフィール、レコード)
- `usePageContext`: ページ判定(トップ/個別)
### OAuth認証フロー
```
1. ユーザーがハンドル入力
2. PDS判定 (syu.is vs bsky.social)
3. 適切なOAuthClientを選択
4. 標準OAuth画面にリダイレクト
5. 認証完了後コールバック処理
6. セッション復元・保存
```
## 重要な実装詳細
### セッション管理
`@atproto/oauth-client-browser`が自動的に以下を処理:
- IndexedDBへのセッション保存
- トークンの自動更新
- DPoPDemonstration of Proof of Possession
**注意**: 手動でのセッション管理は複雑なため、公式ライブラリを使用すること。
### PDS判定アルゴリズム
```javascript
// src/utils/pds.js
function isSyuIsHandle(handle) {
return env.handleList.includes(handle) || handle.endsWith(`.${env.pds}`)
}
```
1. `VITE_HANDLE_LIST` に含まれるハンドル → syu.is
2. `.syu.is` で終わるハンドル → syu.is
3. その他 → bsky.social
### レコードフィルタリング
```javascript
// src/components/RecordTabs.jsx
const filterRecords = (records) => {
if (pageContext.isTopPage) {
return records.slice(0, 3) // 最新3件
} else {
// URL のrkey と record.value.post.url のrkey を照合
return records.filter(record => {
const recordRkey = new URL(record.value.post.url).pathname.split('/').pop()?.replace(/\.html$/, '')
return recordRkey === pageContext.rkey
})
}
}
```
## 開発時の注意点
### 1. 環境変数の命名
- `VITE_` プレフィックス必須Viteの制約
- JSON形式の環境変数は文字列として定義
```bash
# ❌ 間違い
VITE_HANDLE_LIST=["ai.syui.ai"]
# ✅ 正しい
VITE_HANDLE_LIST=["ai.syui.ai", "syui.syui.ai"]
```
### 2. API エラーハンドリング
```javascript
// src/api/atproto.js
async function request(url) {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
return await response.json()
}
```
すべてのAPI呼び出しでエラーハンドリングを実装。
### 3. コンポーネント設計
```javascript
// ❌ Bad: ビジネスロジックがコンポーネント内
function MyComponent() {
const [data, setData] = useState([])
useEffect(() => {
fetch('/api/data').then(setData)
}, [])
return <div>{data.map(...)}</div>
}
// ✅ Good: Hooksでロジック分離
function MyComponent() {
const { data, loading, error } = useMyData()
if (loading) return <Loading />
if (error) return <Error />
return <div>{data.map(...)}</div>
}
```
## デバッグ手法
### 1. OAuth デバッグ
```javascript
// ブラウザの開発者ツールで確認
localStorage.clear() // セッションクリア
sessionStorage.clear() // 一時データクリア
// IndexedDB確認Application タブ)
// ATProtoの認証データが保存される
```
### 2. PDS判定デバッグ
```javascript
// src/utils/pds.js にログ追加
console.log('Handle:', handle)
console.log('Is syu.is:', isSyuIsHandle(handle))
console.log('API Config:', getApiConfig(pds))
```
### 3. レコードフィルタリングデバッグ
```javascript
// src/components/RecordTabs.jsx
console.log('Page Context:', pageContext)
console.log('All Records:', records.length)
console.log('Filtered Records:', filteredRecords.length)
```
## パフォーマンス最適化
### 1. 並列データ取得
```javascript
// src/hooks/useAdminData.js
const [records, lang, comment] = await Promise.all([
collections.getBase(apiConfig.pds, did, env.collection),
collections.getLang(apiConfig.pds, did, env.collection),
collections.getComment(apiConfig.pds, did, env.collection)
])
```
### 2. 不要な再レンダリング防止
```javascript
// useMemo でフィルタリング結果をキャッシュ
const filteredRecords = useMemo(() =>
filterRecords(records),
[records, pageContext]
)
```
## テスト戦略
### 1. 単体テスト推奨対象
- `src/utils/pds.js` - PDS判定ロジック
- `src/config/env.js` - 環境変数パース
- フィルタリング関数
### 2. 統合テスト推奨対象
- OAuth認証フロー
- API呼び出し
- レコード表示
## デプロイメント
### 1. 必要ファイル
```
public/
└── client-metadata.json # OAuth設定ファイル
dist/ # ビルド出力
├── index.html
└── assets/
├── comment-atproto-[hash].js
└── comment-atproto-[hash].css
```
### 2. デプロイ手順
```bash
# 1. 環境変数設定
cp .env.example .env
# 2. 本番用設定を記入
# 3. ビルド
npm run build
# 4. dist/ フォルダをデプロイ
```
### 3. 本番環境チェックリスト
- [ ] `.env` ファイルの本番設定
- [ ] `client-metadata.json` の設置
- [ ] HTTPS 必須OAuth要件
- [ ] CSPContent Security Policy設定
## よくある問題と解決法
### 1. "OAuth initialization failed"
**原因**: client-metadata.json が見つからない、または形式が正しくない
**解決法**:
```bash
# public/client-metadata.json の存在確認
ls -la public/client-metadata.json
# 形式確認JSON validation
jq . public/client-metadata.json
```
### 2. "Failed to load admin data"
**原因**: 管理者アカウントのDID解決に失敗
**解決法**:
```bash
# 手動でDID解決確認
curl "https://syu.is/xrpc/com.atproto.repo.describeRepo?repo=ai.syui.ai"
```
### 3. レコードが表示されない
**原因**: コレクション名の不一致、権限不足
**解決法**:
```bash
# コレクション確認
curl "https://syu.is/xrpc/com.atproto.repo.listRecords?repo=did:plc:xxx&collection=ai.syui.log.chat.lang"
```
## 機能拡張ガイド
### 1. 新しいコレクション追加
```javascript
// src/api/atproto.js に追加
export const collections = {
// 既存...
async getNewCollection(pds, repo, collection, limit = 10) {
return await atproto.getRecords(pds, repo, `${collection}.new`, limit)
}
}
```
### 2. 新しいPDS対応
```javascript
// src/utils/pds.js を拡張
export function getApiConfig(pds) {
if (pds.includes('syu.is')) {
// 既存の syu.is 設定
} else if (pds.includes('newpds.com')) {
return {
pds: `https://newpds.com`,
bsky: `https://bsky.newpds.com`,
plc: `https://plc.newpds.com`,
web: `https://web.newpds.com`
}
}
// デフォルト設定
}
```
### 3. リアルタイム更新追加
```javascript
// src/hooks/useRealtimeUpdates.js
export function useRealtimeUpdates(collection) {
useEffect(() => {
const ws = new WebSocket('wss://jetstream2.us-east.bsky.network/subscribe')
ws.onmessage = (event) => {
const data = JSON.parse(event.data)
if (data.collection === collection) {
// 新しいレコードを追加
}
}
return () => ws.close()
}, [collection])
}
```

222
oauth_new/README.md Normal file
View File

@@ -0,0 +1,222 @@
# ATProto OAuth Comment System
ATProtocolBlueskyのOAuth認証を使用したコメントシステムです。
## プロジェクト概要
このプロジェクトは、ATProtocolネットワーク上のコメントとlangレコードを表示するWebアプリケーションです。
- 標準的なOAuth認証画面を使用
- タブ切り替えでレコード表示
- ページコンテキストに応じたフィルタリング
## ファイル構成
```
src/
├── config/
│ └── env.js # 環境変数の一元管理
├── utils/
│ └── pds.js # PDS判定・API設定ユーティリティ
├── api/
│ └── atproto.js # ATProto API クライアント
├── hooks/
│ ├── useAuth.js # OAuth認証フック
│ ├── useAdminData.js # 管理者データ取得フック
│ └── usePageContext.js # ページ判定フック
├── services/
│ └── oauth.js # OAuth認証サービス
├── components/
│ ├── AuthButton.jsx # ログイン/ログアウトボタン
│ ├── RecordTabs.jsx # Lang/Commentタブ切り替え
│ ├── RecordList.jsx # レコード表示リスト
│ ├── UserLookup.jsx # ユーザー検索(未使用)
│ └── OAuthCallback.jsx # OAuth コールバック処理
└── App.jsx # メインアプリケーション
```
## 環境設定
### .env ファイル
```bash
VITE_ADMIN=ai.syui.ai # 管理者ハンドル
VITE_PDS=syu.is # デフォルトPDS
VITE_HANDLE_LIST=["ai.syui.ai", "syui.syui.ai", "ai.ai"] # syu.is系ハンドルリスト
VITE_COLLECTION=ai.syui.log # ベースコレクション
VITE_OAUTH_CLIENT_ID=https://syui.ai/client-metadata.json # OAuth クライアントID
VITE_OAUTH_REDIRECT_URI=https://syui.ai/oauth/callback # OAuth リダイレクトURI
```
### 必要な依存関係
```json
{
"dependencies": {
"@atproto/api": "^0.15.12",
"@atproto/oauth-client-browser": "^0.3.19",
"react": "^18.2.0",
"react-dom": "^18.2.0"
}
}
```
## 主要機能
### 1. OAuth認証システム
**実装場所**: `src/services/oauth.js`
- `@atproto/oauth-client-browser`を使用した標準OAuth実装
- bsky.social と syu.is 両方のPDSに対応
- セッション自動復元機能
**重要**: ATProtoのセッション管理は複雑なため、公式ライブラリの使用が必須です。
### 2. PDS判定システム
**実装場所**: `src/utils/pds.js`
```javascript
// ハンドル判定ロジック
isSyuIsHandle(handle) boolean
// PDS設定取得
getApiConfig(pds) { pds, bsky, plc, web }
```
環境変数`VITE_HANDLE_LIST``VITE_PDS`を基に自動判定します。
### 3. コレクション取得システム
**実装場所**: `src/api/atproto.js`
```javascript
// 基本コレクション
collections.getBase(pds, repo, collection)
// lang コレクション(翻訳系)
collections.getLang(pds, repo, collection) // → {collection}.chat.lang
// comment コレクション(コメント系)
collections.getComment(pds, repo, collection) // → {collection}.chat.comment
```
### 4. ページコンテキスト判定
**実装場所**: `src/hooks/usePageContext.js`
```javascript
// URL解析結果
{
isTopPage: boolean, // トップページかどうか
rkey: string | null, // 個別ページのrkey/posts/xxx → xxx
url: string // 現在のURL
}
```
## 表示ロジック
### フィルタリング
1. **トップページ**: 最新3件を表示
2. **個別ページ**: `record.value.post.url`の rkey が現在ページと一致するもののみ表示
### タブ切り替え
- Lang Records: `{collection}.chat.lang`
- Comment Records: `{collection}.chat.comment`
## 開発・デバッグ
### 起動コマンド
```bash
npm install
npm run dev # 開発サーバー
npm run build # プロダクションビルド
```
### OAuth デバッグ
1. **ローカル開発**: 自動的にloopback clientが使用される
2. **本番環境**: `client-metadata.json`が必要
```json
// public/client-metadata.json
{
"client_id": "https://syui.ai/client-metadata.json",
"client_name": "ATProto Comment System",
"redirect_uris": ["https://syui.ai/oauth/callback"],
"scope": "atproto",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
"application_type": "web",
"dpop_bound_access_tokens": true
}
```
### よくある問題
1. **セッションが保存されない**
- `@atproto/oauth-client-browser`のバージョン確認
- IndexedDBの確認ブラウザの開発者ツール
2. **PDS判定が正しく動作しない**
- `VITE_HANDLE_LIST`の JSON 形式を確認
- 環境変数の読み込み確認
3. **レコードが表示されない**
- 管理者アカウントの DID 解決確認
- コレクション名の確認(`{base}.chat.lang`, `{base}.chat.comment`
## API エンドポイント
### 使用しているATProto API
1. **com.atproto.repo.describeRepo**
- ハンドル → DID, PDS解決
2. **app.bsky.actor.getProfile**
- プロフィール情報取得
3. **com.atproto.repo.listRecords**
- コレクションレコード取得
## セキュリティ
- OAuth 2.1 + PKCE による認証
- DPoP (Demonstration of Proof of Possession) 対応
- セッション情報はブラウザのIndexedDBに暗号化保存
## 今後の拡張可能性
1. **コメント投稿機能**
- 認証済みユーザーによるコメント作成
- `com.atproto.repo.putRecord` API使用
2. **リアルタイム更新**
- Jetstream WebSocket 接続
- 新しいレコードの自動表示
3. **マルチPDS対応**
- より多くのPDSへの対応
- 動的PDS判定の改善
## トラブルシューティング
### ログ確認
ブラウザの開発者ツールでコンソールログを確認してください。主要なエラーは以下の通りです:
- `OAuth initialization failed`: OAuth設定の問題
- `Failed to load admin data`: API アクセスエラー
- `Auth check failed`: セッション復元エラー
### 環境変数確認
```javascript
// 開発者ツールのコンソールで確認
console.log(import.meta.env)
```
## 参考資料
- [ATProto OAuth Guide](https://github.com/bluesky-social/atproto/blob/main/packages/api/OAUTH.md)
- [BrowserOAuthClient Documentation](https://github.com/bluesky-social/atproto/tree/main/packages/oauth-client-browser)
- [ATProto API Reference](https://docs.bsky.app/docs/advanced-guides/atproto-api)

11
oauth_new/index.html Normal file
View File

@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Comments Test</title>
</head>
<body>
<div id="comment-atproto"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

22
oauth_new/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "oauth-simple",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"@atproto/api": "^0.15.12",
"@atproto/oauth-client-browser": "^0.3.19"
},
"devDependencies": {
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.0.0",
"vite": "^5.0.0"
}
}

76
oauth_new/src/App.jsx Normal file
View File

@@ -0,0 +1,76 @@
import React from 'react'
import { useAuth } from './hooks/useAuth.js'
import { useAdminData } from './hooks/useAdminData.js'
import { useUserData } from './hooks/useUserData.js'
import { usePageContext } from './hooks/usePageContext.js'
import AuthButton from './components/AuthButton.jsx'
import RecordTabs from './components/RecordTabs.jsx'
import CommentForm from './components/CommentForm.jsx'
import OAuthCallback from './components/OAuthCallback.jsx'
export default function App() {
const { user, agent, loading: authLoading, login, logout } = useAuth()
const { adminData, langRecords, commentRecords, loading: dataLoading, error, refresh: refreshAdminData } = useAdminData()
const { userComments, chatRecords, loading: userLoading, refresh: refreshUserData } = useUserData(adminData)
const pageContext = usePageContext()
// Handle OAuth callback
if (window.location.search.includes('code=')) {
return <OAuthCallback />
}
const isLoading = authLoading || dataLoading || userLoading
if (isLoading) {
return (
<div style={{ padding: '20px', textAlign: 'center' }}>
<h1>ATProto OAuth Demo</h1>
<p>読み込み中...</p>
</div>
)
}
if (error) {
return (
<div style={{ padding: '20px', textAlign: 'center' }}>
<h1>ATProto OAuth Demo</h1>
<p style={{ color: 'red' }}>エラー: {error}</p>
<button onClick={() => window.location.reload()}>
再読み込み
</button>
</div>
)
}
return (
<div style={{ padding: '20px', maxWidth: '800px', margin: '0 auto' }}>
<header style={{ marginBottom: '20px' }}>
<h1>ATProto OAuth Demo</h1>
<AuthButton
user={user}
onLogin={login}
onLogout={logout}
loading={authLoading}
/>
</header>
<CommentForm
user={user}
agent={agent}
onCommentPosted={() => {
refreshAdminData?.()
refreshUserData?.()
}}
/>
<RecordTabs
langRecords={langRecords}
commentRecords={commentRecords}
userComments={userComments}
chatRecords={chatRecords}
apiConfig={adminData.apiConfig}
pageContext={pageContext}
/>
</div>
)
}

View File

@@ -0,0 +1,80 @@
// ATProto API client
const ENDPOINTS = {
describeRepo: 'com.atproto.repo.describeRepo',
getProfile: 'app.bsky.actor.getProfile',
listRecords: 'com.atproto.repo.listRecords',
putRecord: 'com.atproto.repo.putRecord'
}
async function request(url, options = {}) {
const response = await fetch(url, options)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
return await response.json()
}
export const atproto = {
async getDid(pds, handle) {
const res = await request(`https://${pds}/xrpc/${ENDPOINTS.describeRepo}?repo=${handle}`)
return res.did
},
async getProfile(bsky, actor) {
return await request(`${bsky}/xrpc/${ENDPOINTS.getProfile}?actor=${actor}`)
},
async getRecords(pds, repo, collection, limit = 10) {
const res = await request(`${pds}/xrpc/${ENDPOINTS.listRecords}?repo=${repo}&collection=${collection}&limit=${limit}`)
return res.records || []
},
async searchPlc(plc, did) {
try {
const data = await request(`${plc}/${did}`)
return {
success: true,
endpoint: data?.service?.[0]?.serviceEndpoint || null,
handle: data?.alsoKnownAs?.[0]?.replace('at://', '') || null
}
} catch {
return { success: false, endpoint: null, handle: null }
}
},
async putRecord(pds, record, agent) {
if (!agent) {
throw new Error('Agent required for putRecord')
}
// Use Agent's putRecord method instead of direct fetch
return await agent.com.atproto.repo.putRecord(record)
}
}
// Collection specific methods
export const collections = {
async getBase(pds, repo, collection, limit = 10) {
return await atproto.getRecords(pds, repo, collection, limit)
},
async getLang(pds, repo, collection, limit = 10) {
return await atproto.getRecords(pds, repo, `${collection}.chat.lang`, limit)
},
async getComment(pds, repo, collection, limit = 10) {
return await atproto.getRecords(pds, repo, `${collection}.chat.comment`, limit)
},
async getChat(pds, repo, collection, limit = 10) {
return await atproto.getRecords(pds, repo, `${collection}.chat`, limit)
},
async getUserList(pds, repo, collection, limit = 100) {
return await atproto.getRecords(pds, repo, `${collection}.user`, limit)
},
async getUserComments(pds, repo, collection, limit = 10) {
return await atproto.getRecords(pds, repo, collection, limit)
}
}

View File

@@ -0,0 +1,102 @@
import React, { useState } from 'react'
export default function AuthButton({ user, onLogin, onLogout, loading }) {
const [handleInput, setHandleInput] = useState('')
const [isLoading, setIsLoading] = useState(false)
const handleSubmit = async (e) => {
e.preventDefault()
if (!handleInput.trim() || isLoading) return
setIsLoading(true)
try {
await onLogin(handleInput.trim())
} catch (error) {
console.error('Login failed:', error)
alert('ログインに失敗しました: ' + error.message)
} finally {
setIsLoading(false)
}
}
if (loading) {
return <div>認証状態を確認中...</div>
}
if (user) {
return (
<div className="auth-status">
<div>ログイン中: <strong>{user.handle}</strong></div>
<button onClick={onLogout} className="logout-btn">
ログアウト
</button>
<style jsx>{`
.auth-status {
padding: 10px;
border: 1px solid #ddd;
border-radius: 5px;
background: #f9f9f9;
}
.logout-btn {
margin-top: 5px;
padding: 5px 10px;
background: #dc3545;
color: white;
border: none;
border-radius: 3px;
cursor: pointer;
}
`}</style>
</div>
)
}
return (
<div className="auth-form">
<h3>OAuth認証</h3>
<form onSubmit={handleSubmit}>
<input
type="text"
value={handleInput}
onChange={(e) => setHandleInput(e.target.value)}
placeholder="Handle (e.g. your.handle.com)"
disabled={isLoading}
className="handle-input"
/>
<button
type="submit"
disabled={isLoading || !handleInput.trim()}
className="login-btn"
>
{isLoading ? 'ログイン中...' : 'ログイン'}
</button>
</form>
<style jsx>{`
.auth-form {
padding: 10px;
border: 1px solid #ddd;
border-radius: 5px;
}
.handle-input {
width: 200px;
margin-right: 10px;
padding: 5px;
border: 1px solid #ccc;
border-radius: 3px;
}
.login-btn {
padding: 5px 10px;
background: #007bff;
color: white;
border: none;
border-radius: 3px;
cursor: pointer;
}
.login-btn:disabled {
background: #ccc;
cursor: not-allowed;
}
`}</style>
</div>
)
}

View File

@@ -0,0 +1,200 @@
import React, { useState } from 'react'
import { atproto } from '../api/atproto.js'
import { env } from '../config/env.js'
export default function CommentForm({ user, agent, onCommentPosted }) {
const [text, setText] = useState('')
const [url, setUrl] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const handleSubmit = async (e) => {
e.preventDefault()
if (!text.trim() || !url.trim()) return
setLoading(true)
setError(null)
try {
// Create ai.syui.log record structure
const record = {
repo: user.did,
collection: env.collection,
rkey: `comment-${Date.now()}`,
record: {
$type: env.collection,
url: url.trim(),
comments: [
{
url: url.trim(),
text: text.trim(),
author: {
did: user.did,
handle: user.handle,
displayName: user.displayName,
avatar: user.avatar
},
createdAt: new Date().toISOString()
}
],
createdAt: new Date().toISOString()
}
}
// Post the record
await atproto.putRecord(null, record, agent)
// Clear form
setText('')
setUrl('')
// Notify parent component
if (onCommentPosted) {
onCommentPosted()
}
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
if (!user) {
return (
<div className="comment-form-placeholder">
<p>ログインしてコメントを投稿</p>
</div>
)
}
return (
<div className="comment-form">
<h3>コメントを投稿</h3>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="comment-url">ページURL:</label>
<input
id="comment-url"
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://syui.ai/posts/example"
required
disabled={loading}
/>
</div>
<div className="form-group">
<label htmlFor="comment-text">コメント:</label>
<textarea
id="comment-text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="コメントを入力してください..."
rows={4}
required
disabled={loading}
/>
</div>
{error && (
<div className="error-message">
エラー: {error}
</div>
)}
<div className="form-actions">
<button
type="submit"
disabled={loading || !text.trim() || !url.trim()}
className="submit-btn"
>
{loading ? '投稿中...' : 'コメントを投稿'}
</button>
</div>
</form>
<style jsx>{`
.comment-form {
border: 2px solid #007bff;
border-radius: 8px;
padding: 20px;
margin: 20px 0;
background: #f8f9fa;
}
.comment-form-placeholder {
border: 2px dashed #ddd;
border-radius: 8px;
padding: 20px;
margin: 20px 0;
text-align: center;
color: #666;
background: #f8f9fa;
}
.comment-form h3 {
margin-top: 0;
color: #007bff;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
color: #333;
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
box-sizing: border-box;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
}
.form-group input:disabled,
.form-group textarea:disabled {
background: #e9ecef;
cursor: not-allowed;
}
.error-message {
background: #f8d7da;
color: #721c24;
padding: 10px;
border-radius: 4px;
margin-bottom: 15px;
border: 1px solid #f5c6cb;
}
.form-actions {
margin-top: 20px;
}
.submit-btn {
background: #007bff;
color: white;
border: none;
padding: 10px 20px;
border-radius: 4px;
font-size: 16px;
cursor: pointer;
transition: background 0.2s;
}
.submit-btn:hover:not(:disabled) {
background: #0056b3;
}
.submit-btn:disabled {
background: #6c757d;
cursor: not-allowed;
}
`}</style>
</div>
)
}

View File

@@ -0,0 +1,50 @@
import React, { useEffect, useState } from 'react'
export default function OAuthCallback({ onAuthSuccess }) {
const [status, setStatus] = useState('OAuth認証処理中...')
useEffect(() => {
handleCallback()
}, [])
const handleCallback = async () => {
try {
// BrowserOAuthClientが自動的にコールバックを処理します
// URLのパラメータを確認して成功を通知
const urlParams = new URLSearchParams(window.location.search)
const code = urlParams.get('code')
const error = urlParams.get('error')
if (error) {
throw new Error(`OAuth error: ${error}`)
}
if (code) {
setStatus('認証成功!メインページに戻ります...')
// 少し待ってからメインページにリダイレクト
setTimeout(() => {
window.location.href = '/'
}, 1500)
} else {
setStatus('認証情報が見つかりません')
}
} catch (error) {
console.error('Callback error:', error)
setStatus('認証エラー: ' + error.message)
}
}
return (
<div style={{ padding: '20px', textAlign: 'center' }}>
<h2>OAuth認証</h2>
<p>{status}</p>
{status.includes('エラー') && (
<button onClick={() => window.location.href = '/'}>
メインページに戻る
</button>
)}
</div>
)
}

View File

@@ -0,0 +1,58 @@
import React from 'react'
export default function RecordList({ title, records, apiConfig, showTitle = true }) {
if (!records || records.length === 0) {
return (
<section>
{showTitle && <h3>{title} (0)</h3>}
<p>レコードがありません</p>
</section>
)
}
return (
<section>
{showTitle && <h3>{title} ({records.length})</h3>}
{records.map((record, i) => (
<div key={i} style={{ border: '1px solid #ddd', margin: '10px 0', padding: '10px' }}>
{record.value.author?.avatar && (
<img
src={record.value.author.avatar}
alt="avatar"
style={{ width: '32px', height: '32px', borderRadius: '50%', marginRight: '10px' }}
/>
)}
<div><strong>{record.value.author?.displayName || record.value.author?.handle}</strong></div>
<div>
Handle:
<a
href={`${apiConfig?.web}/profile/${record.value.author?.did}`}
target="_blank"
rel="noopener noreferrer"
style={{ marginLeft: '5px' }}
>
{record.value.author?.handle}
</a>
</div>
<div style={{ margin: '10px 0' }}>{record.value.text || record.value.content}</div>
{record.value.post?.url && (
<div>
URL:
<a
href={record.value.post.url}
target="_blank"
rel="noopener noreferrer"
style={{ marginLeft: '5px' }}
>
{record.value.post.url}
</a>
</div>
)}
<div style={{ fontSize: '12px', color: '#666', marginTop: '10px' }}>
{new Date(record.value.createdAt).toLocaleString()}
</div>
</div>
))}
</section>
)
}

View File

@@ -0,0 +1,151 @@
import React, { useState } from 'react'
import RecordList from './RecordList.jsx'
export default function RecordTabs({ langRecords, commentRecords, userComments, chatRecords, apiConfig, pageContext }) {
const [activeTab, setActiveTab] = useState('lang')
// Filter records based on page context
const filterRecords = (records) => {
if (pageContext.isTopPage) {
// Top page: show latest 3 records
return records.slice(0, 3)
} else {
// Individual page: show records matching the URL
return records.filter(record => {
const recordUrl = record.value.post?.url
if (!recordUrl) return false
try {
const recordRkey = new URL(recordUrl).pathname.split('/').pop()?.replace(/\.html$/, '')
return recordRkey === pageContext.rkey
} catch {
return false
}
})
}
}
const filteredLangRecords = filterRecords(langRecords)
const filteredCommentRecords = filterRecords(commentRecords)
const filteredUserComments = filterRecords(userComments || [])
const filteredChatRecords = filterRecords(chatRecords || [])
return (
<div className="record-tabs">
<div className="tab-header">
<button
className={`tab-btn ${activeTab === 'lang' ? 'active' : ''}`}
onClick={() => setActiveTab('lang')}
>
Lang Records ({filteredLangRecords.length})
</button>
<button
className={`tab-btn ${activeTab === 'comment' ? 'active' : ''}`}
onClick={() => setActiveTab('comment')}
>
Comment Records ({filteredCommentRecords.length})
</button>
<button
className={`tab-btn ${activeTab === 'collection' ? 'active' : ''}`}
onClick={() => setActiveTab('collection')}
>
Collection ({filteredChatRecords.length})
</button>
<button
className={`tab-btn ${activeTab === 'users' ? 'active' : ''}`}
onClick={() => setActiveTab('users')}
>
User Comments ({filteredUserComments.length})
</button>
</div>
<div className="tab-content">
{activeTab === 'lang' && (
<RecordList
title={pageContext.isTopPage ? "Latest Lang Records" : "Lang Records for this page"}
records={filteredLangRecords}
apiConfig={apiConfig}
/>
)}
{activeTab === 'comment' && (
<RecordList
title={pageContext.isTopPage ? "Latest Comment Records" : "Comment Records for this page"}
records={filteredCommentRecords}
apiConfig={apiConfig}
/>
)}
{activeTab === 'collection' && (
<RecordList
title={pageContext.isTopPage ? "Latest Collection Records" : "Collection Records for this page"}
records={filteredChatRecords}
apiConfig={apiConfig}
/>
)}
{activeTab === 'users' && (
<RecordList
title={pageContext.isTopPage ? "Latest User Comments" : "User Comments for this page"}
records={filteredUserComments}
apiConfig={apiConfig}
/>
)}
</div>
<div className="page-info">
<small>
{pageContext.isTopPage
? "トップページ: 最新3件を表示"
: `個別ページ: ${pageContext.rkey} に関連するレコードを表示`
}
</small>
</div>
<style jsx>{`
.record-tabs {
margin: 20px 0;
}
.tab-header {
display: flex;
border-bottom: 2px solid #ddd;
margin-bottom: 10px;
}
.tab-btn {
padding: 10px 20px;
border: none;
background: #f8f9fa;
border-top: 2px solid transparent;
border-left: 1px solid #ddd;
border-right: 1px solid #ddd;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.tab-btn:first-child {
border-left: none;
}
.tab-btn:last-child {
border-right: none;
}
.tab-btn.active {
background: white;
border-top-color: #007bff;
border-bottom: 2px solid white;
margin-bottom: -2px;
font-weight: bold;
}
.tab-btn:hover:not(.active) {
background: #e9ecef;
}
.tab-content {
min-height: 200px;
}
.page-info {
margin-top: 10px;
padding: 5px 10px;
background: #f8f9fa;
border-radius: 3px;
color: #666;
}
`}</style>
</div>
)
}

View File

@@ -0,0 +1,115 @@
import React, { useState } from 'react'
import { atproto } from '../api/atproto.js'
import { getPdsFromHandle, getApiConfig } from '../utils/pds.js'
export default function UserLookup() {
const [handleInput, setHandleInput] = useState('')
const [userInfo, setUserInfo] = useState(null)
const [loading, setLoading] = useState(false)
const handleSubmit = async (e) => {
e.preventDefault()
if (!handleInput.trim() || loading) return
setLoading(true)
try {
const userPds = await getPdsFromHandle(handleInput)
const apiConfig = getApiConfig(userPds)
const did = await atproto.getDid(userPds.replace('https://', ''), handleInput)
const profile = await atproto.getProfile(apiConfig.bsky, did)
setUserInfo({
handle: handleInput,
pds: userPds,
did,
profile,
config: apiConfig
})
} catch (error) {
console.error('User lookup failed:', error)
setUserInfo({ error: error.message })
} finally {
setLoading(false)
}
}
return (
<section className="user-lookup">
<h3>ユーザー検索</h3>
<form onSubmit={handleSubmit}>
<input
type="text"
value={handleInput}
onChange={(e) => setHandleInput(e.target.value)}
placeholder="Enter handle (e.g. syui.syui.ai)"
disabled={loading}
className="search-input"
/>
<button
type="submit"
disabled={loading || !handleInput.trim()}
className="search-btn"
>
{loading ? '検索中...' : '検索'}
</button>
</form>
{userInfo && (
<div className="user-result">
<h4>ユーザー情報:</h4>
{userInfo.error ? (
<div className="error">エラー: {userInfo.error}</div>
) : (
<div className="user-details">
<div>Handle: {userInfo.handle}</div>
<div>PDS: {userInfo.pds}</div>
<div>DID: {userInfo.did}</div>
<div>Display Name: {userInfo.profile?.displayName}</div>
<div>PDS API: {userInfo.config?.pds}</div>
<div>Bsky API: {userInfo.config?.bsky}</div>
<div>Web: {userInfo.config?.web}</div>
</div>
)}
</div>
)}
<style jsx>{`
.user-lookup {
margin: 20px 0;
}
.search-input {
width: 200px;
margin-right: 10px;
padding: 5px;
border: 1px solid #ccc;
border-radius: 3px;
}
.search-btn {
padding: 5px 10px;
background: #28a745;
color: white;
border: none;
border-radius: 3px;
cursor: pointer;
}
.search-btn:disabled {
background: #ccc;
cursor: not-allowed;
}
.user-result {
margin-top: 15px;
padding: 10px;
border: 1px solid #ddd;
border-radius: 5px;
background: #f9f9f9;
}
.error {
color: #dc3545;
}
.user-details div {
margin: 5px 0;
}
`}</style>
</section>
)
}

View File

@@ -0,0 +1,17 @@
// Environment configuration
export const env = {
admin: import.meta.env.VITE_ADMIN,
pds: import.meta.env.VITE_PDS,
collection: import.meta.env.VITE_COLLECTION,
handleList: (() => {
try {
return JSON.parse(import.meta.env.VITE_HANDLE_LIST || '[]')
} catch {
return []
}
})(),
oauth: {
clientId: import.meta.env.VITE_OAUTH_CLIENT_ID,
redirectUri: import.meta.env.VITE_OAUTH_REDIRECT_URI
}
}

View File

@@ -0,0 +1,57 @@
import { useState, useEffect } from 'react'
import { atproto, collections } from '../api/atproto.js'
import { getApiConfig } from '../utils/pds.js'
import { env } from '../config/env.js'
export function useAdminData() {
const [adminData, setAdminData] = useState({
did: '',
profile: null,
records: [],
apiConfig: null
})
const [langRecords, setLangRecords] = useState([])
const [commentRecords, setCommentRecords] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
loadAdminData()
}, [])
const loadAdminData = async () => {
try {
setLoading(true)
setError(null)
const apiConfig = getApiConfig(`https://${env.pds}`)
const did = await atproto.getDid(env.pds, env.admin)
const profile = await atproto.getProfile(apiConfig.bsky, did)
// Load all data in parallel
const [records, lang, comment] = await Promise.all([
collections.getBase(apiConfig.pds, did, env.collection),
collections.getLang(apiConfig.pds, did, env.collection),
collections.getComment(apiConfig.pds, did, env.collection)
])
setAdminData({ did, profile, records, apiConfig })
setLangRecords(lang)
setCommentRecords(comment)
} catch (err) {
console.error('Failed to load admin data:', err)
setError(err.message)
} finally {
setLoading(false)
}
}
return {
adminData,
langRecords,
commentRecords,
loading,
error,
refresh: loadAdminData
}
}

View File

@@ -0,0 +1,47 @@
import { useState, useEffect } from 'react'
import { OAuthService } from '../services/oauth.js'
const oauthService = new OAuthService()
export function useAuth() {
const [user, setUser] = useState(null)
const [agent, setAgent] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
initAuth()
}, [])
const initAuth = async () => {
try {
const authResult = await oauthService.checkAuth()
if (authResult) {
setUser(authResult.user)
setAgent(authResult.agent)
}
} catch (error) {
console.error('Auth initialization failed:', error)
} finally {
setLoading(false)
}
}
const login = async (handle) => {
await oauthService.login(handle)
}
const logout = async () => {
await oauthService.logout()
setUser(null)
setAgent(null)
}
return {
user,
agent,
loading,
login,
logout,
isAuthenticated: !!user
}
}

View File

@@ -0,0 +1,33 @@
import { useState, useEffect } from 'react'
export function usePageContext() {
const [pageContext, setPageContext] = useState({
isTopPage: true,
rkey: null,
url: null
})
useEffect(() => {
const pathname = window.location.pathname
const url = window.location.href
// Extract rkey from URL pattern: /posts/xxx or /posts/xxx.html
const match = pathname.match(/\/posts\/([^/]+)\/?$/)
if (match) {
const rkey = match[1].replace(/\.html$/, '')
setPageContext({
isTopPage: false,
rkey,
url
})
} else {
setPageContext({
isTopPage: true,
rkey: null,
url
})
}
}, [])
return pageContext
}

View File

@@ -0,0 +1,164 @@
import { useState, useEffect } from 'react'
import { atproto, collections } from '../api/atproto.js'
import { getApiConfig, isSyuIsHandle } from '../utils/pds.js'
import { env } from '../config/env.js'
export function useUserData(adminData) {
const [userComments, setUserComments] = useState([])
const [chatRecords, setChatRecords] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
useEffect(() => {
if (!adminData?.did || !adminData?.apiConfig) return
const fetchUserData = async () => {
setLoading(true)
setError(null)
try {
// 1. Get user list from admin account
const userListRecords = await collections.getUserList(
adminData.apiConfig.pds,
adminData.did,
env.collection
)
// 2. Get chat records (ai.syui.log.chat doesn't exist, so skip for now)
setChatRecords([])
// 3. Get base collection records which contain user comments
const baseRecords = await collections.getBase(
adminData.apiConfig.pds,
adminData.did,
env.collection
)
// Extract comments from base records
const allUserComments = []
for (const record of baseRecords) {
if (record.value?.comments && Array.isArray(record.value.comments)) {
// Each comment already has author info, so we can use it directly
const commentsWithMeta = record.value.comments.map(comment => ({
uri: record.uri,
cid: record.cid,
value: {
...comment,
post: {
url: record.value.url
}
}
}))
allUserComments.push(...commentsWithMeta)
}
}
// Also try to get individual user records from the user list
// Currently skipping user list processing since users contain placeholder DIDs
if (userListRecords.length > 0 && userListRecords[0].value?.users) {
console.log('User list found, but skipping placeholder users for now')
// Filter out placeholder users
const realUsers = userListRecords[0].value.users.filter(user =>
user.handle &&
user.did &&
!user.did.includes('placeholder') &&
!user.did.includes('example')
)
if (realUsers.length > 0) {
console.log(`Processing ${realUsers.length} real users`)
for (const user of realUsers) {
const userHandle = user.handle
try {
// Get user's DID and PDS using PDS detection logic
let userDid, userPds, userApiConfig
if (user.did && user.pds) {
// Use DID and PDS from user record
userDid = user.did
userPds = user.pds.replace('https://', '')
userApiConfig = getApiConfig(userPds)
} else {
// Auto-detect PDS based on handle and get real DID
if (isSyuIsHandle(userHandle)) {
userPds = env.pds
userApiConfig = getApiConfig(userPds)
userDid = await atproto.getDid(userPds, userHandle)
} else {
userPds = 'bsky.social'
userApiConfig = getApiConfig(userPds)
userDid = await atproto.getDid(userPds, userHandle)
}
}
// Get user's own ai.syui.log records
const userRecords = await collections.getUserComments(
userApiConfig.pds,
userDid,
env.collection
)
// Skip if no records found
if (!userRecords || userRecords.length === 0) {
continue
}
// Get user's profile for enrichment
let profile = null
try {
profile = await atproto.getProfile(userApiConfig.bsky, userDid)
} catch (profileError) {
console.warn(`Failed to get profile for ${userHandle}:`, profileError)
}
// Add profile info to each record
const enrichedRecords = userRecords.map(record => ({
...record,
value: {
...record.value,
author: {
did: userDid,
handle: profile?.data?.handle || userHandle,
displayName: profile?.data?.displayName || userHandle,
avatar: profile?.data?.avatar || null
}
}
}))
allUserComments.push(...enrichedRecords)
} catch (userError) {
console.warn(`Failed to fetch data for user ${userHandle}:`, userError)
}
}
} else {
console.log('No real users found in user list - all appear to be placeholders')
}
}
setUserComments(allUserComments)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
fetchUserData()
}, [adminData])
const refresh = () => {
if (adminData?.did && adminData?.apiConfig) {
// Re-trigger the effect by clearing and re-setting adminData
const currentAdminData = adminData
setUserComments([])
setChatRecords([])
// The useEffect will automatically run again
}
}
return { userComments, chatRecords, loading, error, refresh }
}

5
oauth_new/src/main.jsx Normal file
View File

@@ -0,0 +1,5 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
ReactDOM.createRoot(document.getElementById('comment-atproto')).render(<App />)

View File

@@ -0,0 +1,144 @@
import { BrowserOAuthClient } from '@atproto/oauth-client-browser'
import { Agent } from '@atproto/api'
import { env } from '../config/env.js'
import { isSyuIsHandle } from '../utils/pds.js'
export class OAuthService {
constructor() {
this.clientId = env.oauth.clientId || this.getClientId()
this.clients = { bsky: null, syu: null }
this.agent = null
this.sessionInfo = null
this.initPromise = null
}
getClientId() {
const origin = window.location.origin
return origin.includes('localhost') || origin.includes('127.0.0.1')
? undefined // Loopback client
: `${origin}/client-metadata.json`
}
async initialize() {
if (this.initPromise) return this.initPromise
this.initPromise = this._initialize()
return this.initPromise
}
async _initialize() {
try {
// Initialize OAuth clients
this.clients.bsky = await BrowserOAuthClient.load({
clientId: this.clientId,
handleResolver: 'https://bsky.social',
plcDirectoryUrl: 'https://plc.directory',
})
this.clients.syu = await BrowserOAuthClient.load({
clientId: this.clientId,
handleResolver: 'https://syu.is',
plcDirectoryUrl: 'https://plc.syu.is',
})
// Try to restore session
return await this.restoreSession()
} catch (error) {
console.error('OAuth initialization failed:', error)
this.initPromise = null
throw error
}
}
async restoreSession() {
// Try both clients
for (const client of [this.clients.bsky, this.clients.syu]) {
const result = await client.init()
if (result?.session) {
this.agent = new Agent(result.session)
return this.processSession(result.session)
}
}
return null
}
async processSession(session) {
const did = session.sub || session.did
let handle = session.handle || 'unknown'
this.sessionInfo = { did, handle }
// Resolve handle if missing
if (handle === 'unknown' && this.agent) {
try {
const profile = await this.agent.getProfile({ actor: did })
handle = profile.data.handle
this.sessionInfo.handle = handle
} catch (error) {
console.log('Failed to resolve handle:', error)
}
}
return { did, handle }
}
async login(handle) {
await this.initialize()
const client = isSyuIsHandle(handle) ? this.clients.syu : this.clients.bsky
const authUrl = await client.authorize(handle, { scope: 'atproto' })
window.location.href = authUrl.toString()
}
async checkAuth() {
try {
await this.initialize()
if (this.sessionInfo) {
return {
user: this.sessionInfo,
agent: this.agent
}
}
return null
} catch (error) {
console.error('Auth check failed:', error)
return null
}
}
async logout() {
try {
// Sign out from session
if (this.clients.bsky) {
const result = await this.clients.bsky.init()
if (result?.session?.signOut) {
await result.session.signOut()
}
}
// Clear state
this.agent = null
this.sessionInfo = null
this.clients = { bsky: null, syu: null }
this.initPromise = null
// Clear storage
localStorage.clear()
sessionStorage.clear()
// Reload page
window.location.reload()
} catch (error) {
console.error('Logout failed:', error)
}
}
getAgent() {
return this.agent
}
getUser() {
return this.sessionInfo
}
}

View File

@@ -0,0 +1,36 @@
import { env } from '../config/env.js'
// PDS判定からAPI設定を取得
export function getApiConfig(pds) {
if (pds.includes(env.pds)) {
return {
pds: `https://${env.pds}`,
bsky: `https://bsky.${env.pds}`,
plc: `https://plc.${env.pds}`,
web: `https://web.${env.pds}`
}
}
return {
pds: pds.startsWith('http') ? pds : `https://${pds}`,
bsky: 'https://public.api.bsky.app',
plc: 'https://plc.directory',
web: 'https://bsky.app'
}
}
// handleがsyu.is系かどうか判定
export function isSyuIsHandle(handle) {
return env.handleList.includes(handle) || handle.endsWith(`.${env.pds}`)
}
// handleからPDS取得
export async function getPdsFromHandle(handle) {
const initialPds = isSyuIsHandle(handle)
? `https://${env.pds}`
: 'https://bsky.social'
const data = await fetch(`${initialPds}/xrpc/com.atproto.repo.describeRepo?repo=${handle}`)
.then(res => res.json())
return data.didDoc?.service?.[0]?.serviceEndpoint || initialPds
}

15
oauth_new/vite.config.js Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
build: {
rollupOptions: {
output: {
entryFileNames: 'assets/comment-atproto-[hash].js',
chunkFileNames: 'assets/comment-atproto-[hash].js',
assetFileNames: 'assets/comment-atproto-[hash].[ext]'
}
}
}
})

View File

@@ -3,16 +3,16 @@
set -e
cb=ai.syui.log
cl=( $cb.chat $cb.chat.comment $cb.chat.lang )
f=~/.config/syui/ai/bot/token.json
cl=( $cb.chat.lang $cb.chat.comment)
f=~/.config/syui/ai/log/config.json
default_collection="ai.syui.log.chat.comment"
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`
default_collection="ai.syui.log.chat"
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}

View File

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

View File

@@ -86,7 +86,125 @@ fn get_config_path() -> Result<PathBuf> {
Ok(config_dir.join("config.json"))
}
#[allow(dead_code)]
pub async fn init() -> Result<()> {
init_with_pds(None).await
}
pub async fn init_with_options(
pds_override: Option<String>,
handle_override: Option<String>,
use_password: bool,
access_jwt_override: Option<String>,
refresh_jwt_override: Option<String>
) -> Result<()> {
println!("{}", "🔐 Initializing ATProto authentication...".cyan());
let config_path = get_config_path()?;
if config_path.exists() {
println!("{}", "⚠️ Configuration already exists. Use 'ailog auth logout' to reset.".yellow());
return Ok(());
}
// Validate options
if let (Some(_), Some(_)) = (&access_jwt_override, &refresh_jwt_override) {
if use_password {
println!("{}", "⚠️ Cannot use both --password and JWT tokens. Choose one method.".yellow());
return Ok(());
}
} else if access_jwt_override.is_some() || refresh_jwt_override.is_some() {
println!("{}", "❌ Both --access-jwt and --refresh-jwt must be provided together.".red());
return Ok(());
}
println!("{}", "📋 Please provide your ATProto credentials:".cyan());
// Get handle
let handle = if let Some(h) = handle_override {
h
} else {
print!("Handle (e.g., your.handle.bsky.social): ");
std::io::Write::flush(&mut std::io::stdout())?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
input.trim().to_string()
};
// Determine PDS URL
let pds_url = if let Some(override_pds) = pds_override {
if override_pds.starts_with("http") {
override_pds
} else {
format!("https://{}", override_pds)
}
} else {
if handle.ends_with(".syu.is") {
"https://syu.is".to_string()
} else {
"https://bsky.social".to_string()
}
};
println!("{}", format!("🌐 Using PDS: {}", pds_url).cyan());
// Get credentials
let (access_jwt, refresh_jwt) = if let (Some(access), Some(refresh)) = (access_jwt_override, refresh_jwt_override) {
println!("{}", "🔑 Using provided JWT tokens".cyan());
(access, refresh)
} else if use_password {
println!("{}", "🔒 Using password authentication".cyan());
authenticate_with_password(&handle, &pds_url).await?
} else {
// Interactive JWT input (legacy behavior)
print!("Access JWT: ");
std::io::Write::flush(&mut std::io::stdout())?;
let mut access_jwt = String::new();
std::io::stdin().read_line(&mut access_jwt)?;
let access_jwt = access_jwt.trim().to_string();
print!("Refresh JWT: ");
std::io::Write::flush(&mut std::io::stdout())?;
let mut refresh_jwt = String::new();
std::io::stdin().read_line(&mut refresh_jwt)?;
let refresh_jwt = refresh_jwt.trim().to_string();
(access_jwt, refresh_jwt)
};
// Resolve DID from handle
println!("{}", "🔍 Resolving DID from handle...".cyan());
let did = resolve_did_with_pds(&handle, &pds_url).await?;
// Create config
let config = AuthConfig {
admin: AdminConfig {
did: did.clone(),
handle: handle.clone(),
access_jwt,
refresh_jwt,
pds: pds_url,
},
jetstream: JetstreamConfig {
url: "wss://jetstream2.us-east.bsky.network/subscribe".to_string(),
collections: vec!["ai.syui.log".to_string()],
},
collections: generate_collection_config(),
};
// Save config
let config_json = serde_json::to_string_pretty(&config)?;
fs::write(&config_path, config_json)?;
println!("{}", "✅ Authentication configured successfully!".green());
println!("📁 Config saved to: {}", config_path.display());
println!("👤 Authenticated as: {} ({})", handle, did);
Ok(())
}
#[allow(dead_code)]
pub async fn init_with_pds(pds_override: Option<String>) -> Result<()> {
println!("{}", "🔐 Initializing ATProto authentication...".cyan());
let config_path = get_config_path()?;
@@ -117,9 +235,28 @@ pub async fn init() -> Result<()> {
std::io::stdin().read_line(&mut refresh_jwt)?;
let refresh_jwt = refresh_jwt.trim().to_string();
// Determine PDS URL
let pds_url = if let Some(override_pds) = pds_override {
// Use provided PDS override
if override_pds.starts_with("http") {
override_pds
} else {
format!("https://{}", override_pds)
}
} else {
// Auto-detect from handle suffix
if handle.ends_with(".syu.is") {
"https://syu.is".to_string()
} else {
"https://bsky.social".to_string()
}
};
println!("{}", format!("🌐 Using PDS: {}", pds_url).cyan());
// Resolve DID from handle
println!("{}", "🔍 Resolving DID from handle...".cyan());
let did = resolve_did(&handle).await?;
let did = resolve_did_with_pds(&handle, &pds_url).await?;
// Create config
let config = AuthConfig {
@@ -128,11 +265,7 @@ pub async fn init() -> Result<()> {
handle: handle.clone(),
access_jwt,
refresh_jwt,
pds: if handle.ends_with(".syu.is") {
"https://syu.is".to_string()
} else {
"https://bsky.social".to_string()
},
pds: pds_url,
},
jetstream: JetstreamConfig {
url: "wss://jetstream2.us-east.bsky.network/subscribe".to_string(),
@@ -152,6 +285,7 @@ pub async fn init() -> Result<()> {
Ok(())
}
#[allow(dead_code)]
async fn resolve_did(handle: &str) -> Result<String> {
let client = reqwest::Client::new();
@@ -178,6 +312,93 @@ async fn resolve_did(handle: &str) -> Result<String> {
Ok(did.to_string())
}
async fn resolve_did_with_pds(handle: &str, pds_url: &str) -> Result<String> {
let client = reqwest::Client::new();
// Try to use the PDS API first
let api_base = if pds_url.contains("syu.is") {
"https://bsky.syu.is"
} else if pds_url.contains("bsky.social") {
"https://public.api.bsky.app"
} else {
// For custom PDS, try to construct API URL
pds_url
};
let url = format!("{}/xrpc/app.bsky.actor.getProfile?actor={}",
api_base, urlencoding::encode(handle));
let response = client.get(&url).send().await?;
if !response.status().is_success() {
return Err(anyhow::anyhow!("Failed to resolve handle using PDS {}: {}", pds_url, response.status()));
}
let profile: serde_json::Value = response.json().await?;
let did = profile["did"].as_str()
.ok_or_else(|| anyhow::anyhow!("DID not found in profile response"))?;
Ok(did.to_string())
}
async fn authenticate_with_password(handle: &str, pds_url: &str) -> Result<(String, String)> {
use std::io::{self, Write};
// Get password securely
print!("Password: ");
io::stdout().flush()?;
let password = rpassword::read_password()
.context("Failed to read password")?;
if password.is_empty() {
return Err(anyhow::anyhow!("Password cannot be empty"));
}
println!("{}", "🔐 Authenticating with ATProto server...".cyan());
let client = reqwest::Client::new();
let auth_url = format!("{}/xrpc/com.atproto.server.createSession", pds_url);
let auth_request = serde_json::json!({
"identifier": handle,
"password": password
});
let response = client
.post(&auth_url)
.header("Content-Type", "application/json")
.json(&auth_request)
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let error_text = response.text().await.unwrap_or_default();
if status.as_u16() == 401 {
return Err(anyhow::anyhow!("Authentication failed: Invalid handle or password"));
} else if status.as_u16() == 400 {
return Err(anyhow::anyhow!("Authentication failed: Bad request (check handle format)"));
} else {
return Err(anyhow::anyhow!("Authentication failed: {} - {}", status, error_text));
}
}
let auth_response: serde_json::Value = response.json().await?;
let access_jwt = auth_response["accessJwt"].as_str()
.ok_or_else(|| anyhow::anyhow!("No access JWT in response"))?
.to_string();
let refresh_jwt = auth_response["refreshJwt"].as_str()
.ok_or_else(|| anyhow::anyhow!("No refresh JWT in response"))?
.to_string();
println!("{}", "✅ Password authentication successful".green());
Ok((access_jwt, refresh_jwt))
}
pub async fn status() -> Result<()> {
let config_path = get_config_path()?;
@@ -200,9 +421,17 @@ pub async fn status() -> Result<()> {
// Test API access
println!("\n{}", "🧪 Testing API access...".cyan());
match test_api_access(&config).await {
match test_api_access_with_auth(&config).await {
Ok(_) => println!("{}", "✅ API access successful".green()),
Err(e) => println!("{}", format!("❌ API access failed: {}", e).red()),
Err(e) => {
println!("{}", format!("❌ Authenticated API access failed: {}", e).red());
// Fallback to public API test
println!("{}", "🔄 Trying public API access...".cyan());
match test_api_access(&config).await {
Ok(_) => println!("{}", "✅ Public API access successful".green()),
Err(e2) => println!("{}", format!("❌ Public API access also failed: {}", e2).red()),
}
}
}
Ok(())

View File

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

View File

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

View File

@@ -71,8 +71,8 @@ impl Default for AiConfig {
Self {
blog_host: "https://syui.ai".to_string(),
ollama_host: "https://ollama.syui.ai".to_string(),
ai_handle: "yui.syui.ai".to_string(),
ai_did: "did:plc:4hqjfn7m6n5hno3doamuhgef".to_string(), // Fallback DID
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(),
@@ -180,10 +180,16 @@ fn load_collection_config_from_project(project_dir: &Path) -> Result<(String, St
Ok((collection_base, collection_user))
}
// Load AI config from project's config.toml
fn load_ai_config_from_project() -> Result<AiConfig> {
// Try to find config.toml in current directory or parent directories
let mut current_dir = std::env::current_dir()?;
// 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
@@ -197,7 +203,7 @@ fn load_ai_config_from_project() -> Result<AiConfig> {
}
}
let config_path = config_path.ok_or_else(|| anyhow::anyhow!("config.toml not found in current directory or parent directories"))?;
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()))?;
@@ -223,15 +229,15 @@ fn load_ai_config_from_project() -> Result<AiConfig> {
// Read AI handle (preferred) or fallback to AI DID
let ai_handle = ai_config
.and_then(|ai| ai.get("ai_handle"))
.and_then(|ai| ai.get("handle"))
.and_then(|v| v.as_str())
.unwrap_or("yui.syui.ai")
.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:4hqjfn7m6n5hno3doamuhgef")
.unwrap_or("did:plc:6qyecktefllvenje24fcxnie")
.to_string();
let model = ai_config
@@ -256,26 +262,29 @@ fn load_ai_config_from_project() -> Result<AiConfig> {
let pds = oauth_config
.and_then(|oauth| oauth.get("pds"))
.and_then(|v| v.as_str())
.unwrap_or("bsky.social")
.unwrap_or("syu.is")
.to_string();
// Get network configuration based on PDS
let network = get_network_config(&pds);
let bsky_api = network.bsky_api.clone();
Ok(AiConfig {
blog_host,
ollama_host,
ai_handle,
ai_did: fallback_ai_did, // Will be resolved from handle at runtime
ai_did: fallback_ai_did,
model,
system_prompt,
bsky_api,
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> {
@@ -340,6 +349,104 @@ fn get_pid_file() -> Result<PathBuf> {
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<()> {
let mut config = load_config_with_refresh().await?;
@@ -421,9 +528,10 @@ pub async fn start(project_dir: Option<PathBuf>, daemon: bool, ai_generate: bool
// Start AI generation monitor if enabled
if ai_generate {
let ai_config = config.clone();
let project_path = project_dir.clone();
tokio::spawn(async move {
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());
sleep(Duration::from_secs(60)).await; // Wait 1 minute before retry
}
@@ -679,6 +787,33 @@ fn get_network_config_from_pds(pds_endpoint: &str) -> NetworkConfig {
}
}
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<()> {
// Get current user list
let current_users = get_current_user_list(config).await?;
@@ -1130,6 +1265,68 @@ fn extract_did_from_uri(uri: &str) -> Option<String> {
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<()> {
println!("{}", "🧪 Testing API access to comments collection...".cyan().bold());
@@ -1229,41 +1426,51 @@ async fn generate_ai_content(content: &str, prompt_type: &str, ai_config: &AiCon
.timeout(std::time::Duration::from_secs(120)) // 2 minute timeout
.build()?;
// Try localhost first (for same-server deployment)
let localhost_url = "http://localhost:11434/api/generate";
match client.post(localhost_url).json(&request).send().await {
Ok(response) if response.status().is_success() => {
let ollama_response: OllamaResponse = response.json().await?;
println!("{}", "✅ Used localhost Ollama".green());
return Ok(ollama_response.response);
}
_ => {
println!("{}", "⚠️ Localhost Ollama not available, trying remote...".yellow());
}
// Use configured Ollama host
let ollama_url = format!("{}/api/generate", ai_config.ollama_host);
// 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(&ollama_url).json(&request);
if !is_local {
println!("{}", format!("🔗 Making request to: {} with Origin: {}", ollama_url, ai_config.blog_host).blue());
request_builder = request_builder.header("Origin", &ai_config.blog_host);
} else {
println!("{}", format!("🔗 Making request to local network: {}", ollama_url).blue());
}
// Fallback to remote host
let remote_url = format!("{}/api/generate", ai_config.ollama_host);
println!("{}", format!("🔗 Making request to: {} with Origin: {}", remote_url, ai_config.blog_host).blue());
let response = client
.post(&remote_url)
.header("Origin", &ai_config.blog_host)
.json(&request)
.send()
.await?;
let response = request_builder.send().await?;
if !response.status().is_success() {
return Err(anyhow::anyhow!("Ollama API request failed: {}", response.status()));
}
let ollama_response: OllamaResponse = response.json().await?;
println!("{}", "Used remote Ollama".green());
println!("{}", "Ollama request successful".green());
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<()> {
// Load AI config from project config.toml or use defaults
let ai_config = load_ai_config_from_project().unwrap_or_else(|e| {
let ai_config = load_ai_config_from_project_dir(project_dir).unwrap_or_else(|e| {
println!("{}", format!("⚠️ Failed to load AI config: {}, using defaults", e).yellow());
AiConfig::default()
});
@@ -1452,32 +1659,23 @@ fn extract_date_from_slug(slug: &str) -> String {
}
async fn get_ai_profile(client: &reqwest::Client, ai_config: &AiConfig) -> Result<serde_json::Value> {
// Resolve AI's actual PDS first
let pds_endpoints = ["https://bsky.social", "https://syu.is"];
let mut network_config = get_network_config("bsky.social"); // Default fallback
let handle = &ai_config.ai_handle;
for pds in &pds_endpoints {
let describe_url = format!("{}/xrpc/com.atproto.repo.describeRepo?repo={}", pds, urlencoding::encode(&ai_config.ai_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() {
network_config = get_network_config_from_pds(endpoint);
break;
}
}
}
}
}
// 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(&ai_config.ai_did));
network_config.bsky_api, urlencoding::encode(handle));
let response = client
.get(&url)
@@ -1485,20 +1683,41 @@ async fn get_ai_profile(client: &reqwest::Client, ai_config: &AiConfig) -> Resul
.await?;
if !response.status().is_success() {
// Fallback to default AI profile
// 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": "yui.syui.ai",
"handle": handle,
"displayName": "ai",
"avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:4hqjfn7m6n5hno3doamuhgef/bafkreiaxkv624mffw3cfyi67ufxtwuwsy2mjw2ygezsvtd44ycbgkfdo2a@jpeg"
"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": ai_config.ai_did,
"handle": profile_data["handle"].as_str().unwrap_or("yui.syui.ai"),
"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()
}))

View File

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

14
src/lib.rs Normal file
View File

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

View File

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

View File

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

View File

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

View File

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