diff --git a/.claude/settings.local.json b/.claude/settings.local.json index b351d71..d9c03cf 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -47,7 +47,8 @@ "Bash(git push:*)", "Bash(git tag:*)", "Bash(../bin/ailog:*)", - "Bash(../target/release/ailog oauth build:*)" + "Bash(../target/release/ailog oauth build:*)", + "Bash(ailog:*)" ], "deny": [] } diff --git a/.gitignore b/.gitignore index 218408e..0752df2 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ node_modules package-lock.json my-blog/static/assets/comment-atproto-* bin/ailog +docs diff --git a/bin/ailog-linux-x86_64.tar.gz b/bin/ailog-linux-x86_64.tar.gz index 7564ab3..0fe3a66 100644 Binary files a/bin/ailog-linux-x86_64.tar.gz and b/bin/ailog-linux-x86_64.tar.gz differ diff --git a/bin/delete-chat-records.zsh b/bin/delete-chat-records.zsh index b46cd09..a2f0f17 100755 --- a/bin/delete-chat-records.zsh +++ b/bin/delete-chat-records.zsh @@ -1,11 +1,11 @@ #!/bin/zsh - -#[collection] [pds] [did] [token] - set -e +cb=ai.syui.log +cl=( $cb.chat $cb.chat.comment $cb.chat.lang ) + f=~/.config/syui/ai/bot/token.json -default_collection="ai.syui.log.chat" +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` @@ -16,27 +16,15 @@ collection=${1:-$default_collection} pds=${2:-$default_pds} did=${3:-$default_did} token=${4:-$default_token} +req=com.atproto.repo.deleteRecord +url=https://$pds/xrpc/$req -delete_record() { - local rkey=$1 - local req="com.atproto.repo.deleteRecord" - local url="https://$pds/xrpc/$req" - local json="{\"collection\":\"$collection\", \"rkey\":\"$rkey\", \"repo\":\"$did\"}" - curl -sL -X POST \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $token" \ - -d "$json" \ - "$url" - - if [ $? -eq 0 ]; then - echo " ✓ Deleted: $rkey" - else - echo " ✗ Failed: $rkey" - fi -} - -rkeys=($(curl -sL "https://$default_pds/xrpc/com.atproto.repo.listRecords?repo=$did&collection=$collection&limit=100"|jq -r ".records[]?.uri"|cut -d '/' -f 5)) -for rkey in "${rkeys[@]}"; do - echo $rkey - delete_record $rkey +for i in $cl; do + echo $i + rkeys=($(curl -sL "https://$default_pds/xrpc/com.atproto.repo.listRecords?repo=$did&collection=$i&limit=100"|jq -r ".records[]?.uri"|cut -d '/' -f 5)) + for rkey in "${rkeys[@]}"; do + echo $rkey + json="{\"collection\":\"$i\", \"rkey\":\"$rkey\", \"repo\":\"$did\"}" + curl -sL -X POST -H "Content-Type: application/json" -H "Authorization: Bearer $token" -d "$json" $url + done done diff --git a/claude.md b/claude.md index 9cddeef..423122e 100644 --- a/claude.md +++ b/claude.md @@ -1,5 +1,19 @@ # エコシステム統合設計書 +## 注意事項 + +`console.log`は絶対に書かないようにしてください。 + +ハードコードしないようにしてください。必ず、`./my-blog/config.toml`や`./oauth/.env.production`を使用するように。または`~/.config/syui/ai/log/config.json`を使用するように。 + +重複する名前のenvを作らないようにしてください。新しい環境変数を作る際は必ず検討してください。 + +```sh +# ダメな例 +VITE_OAUTH_COLLECTION_USER=ai.syui.log.user +VITE_OAUTH_COLLECTION_CHAT=ai.syui.log.chat +``` + ## 中核思想 - **存在子理論**: この世界で最も小さいもの(存在子/ai)の探求 - **唯一性原則**: 現実の個人の唯一性をすべてのシステムで担保 diff --git a/my-blog/static/index.html b/my-blog/static/index.html index 41ee2db..d6aef90 100644 --- a/my-blog/static/index.html +++ b/my-blog/static/index.html @@ -1,3 +1,3 @@ - - \ No newline at end of file + + \ No newline at end of file diff --git a/my-blog/static/index.json b/my-blog/static/index.json new file mode 100644 index 0000000..e642cd0 --- /dev/null +++ b/my-blog/static/index.json @@ -0,0 +1,31 @@ +[ + { + "categories": [], + "contents": "ブログを移行しました。過去のブログはsyui.github.ioにありあます。 gh-pagesからcf-pagesへの移行になります。 自作のailogでbuildしています。 特徴としては、atproto, AIとの連携です。 name: Deploy to Cloudflare Pages on: push: branches: - main workflow_dispatch: jobs: deploy: runs-on: ubuntu-latest permissions: contents: read deployments: write steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Rust uses: actions-rs/toolchain@v1 with: toolchain: stable - name: Build ailog run: cargo build --release - name: Build site with ailog run: | cd my-blog ../target/release/ailog build - name: List public directory run: | ls -la my-blog/public/ - name: Deploy to Cloudflare Pages uses: cloudflare/pages-action@v1 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} projectName: ${{ secrets.CLOUDFLARE_PROJECT_NAME }} directory: my-blog/public gitHubToken: ${{ secrets.GITHUB_TOKEN }} wranglerVersion: '3' url https://syui.pages.dev https://syui.github.io", + "description": "ブログを移行しました。過去のブログはsyui.github.ioにありあます。 \n\ngh-pagesからcf-pagesへの移行になります。\n自作のailogでbuildしています。\n特徴としては、atproto, AIとの連携です。\n\nname: Deploy to Cloudflare Pages\n\non:\n push:\n branches:\n - main\n workfl...", + "formated_time": "Sat Jun 14, 2025", + "href": "https://syui.ai/posts/2025-06-14-blog.html", + "tags": [ + "blog", + "cloudflare", + "github" + ], + "title": "ブログを移行した", + "utc_time": "2025-06-14T00:00:00Z" + }, + { + "categories": [], + "contents": "rustで静的サイトジェネレータを作りました。ailogといいます。hugoからの移行になります。 ailogは、最初にatproto-comment-system(oauth)とask-AIという機能をつけました。 quick start $ git clone https://git.syui.ai/ai/log $ cd log $ cargo build $ ./target/debug/ailog init my-blog $ ./target/debug/ailog serve my-blog install $ cargo install --path . --- $ export CARGO_HOME="$HOME/.cargo" $ export RUSTUP_HOME="$HOME/.rustup" $ export PATH="$HOME/.cargo/bin:$PATH" --- $ which ailog $ ailog -h build deploy $ cd my-blog $ vim config.toml $ ailog new test $ vim content/posts/`date +"%Y-%m-%d"`.md $ ailog build # publicの中身をweb-serverにdeploy $ cp -rf ./public/* ./web-server/root/ atproto-comment-system example $ cd ./oauth $ npm i $ npm run build $ npm run preview # Production environment variables VITE_APP_HOST=https://example.com VITE_OAUTH_CLIENT_ID=https://example.com/client-metadata.json VITE_OAUTH_REDIRECT_URI=https://example.com/oauth/callback VITE_ADMIN_DID=did:plc:uqzpqmrjnptsxezjx4xuh2mn # Collection names for OAuth app VITE_COLLECTION_COMMENT=ai.syui.log VITE_COLLECTION_USER=ai.syui.log.user VITE_COLLECTION_CHAT=ai.syui.log.chat # Collection names for ailog (backward compatibility) AILOG_COLLECTION_COMMENT=ai.syui.log AILOG_COLLECTION_USER=ai.syui.log.user # API Configuration VITE_BSKY_PUBLIC_API=https://public.api.bsky.app これはailog oauth build my-blogで./my-blog/config.tomlから./oauth/.env.productionが生成されます。 $ ailog oauth build my-blog use 簡単に説明すると、./oauthで生成するのがatproto-comment-systemです。 <script type="module" crossorigin src="/assets/comment-atproto-${hash}}.js"></script> <link rel="stylesheet" crossorigin href="/assets/comment-atproto-${hash}.css"> <section class="comment-section"> <div id="comment-atproto"></div> </section> ただし、oauthであるため、色々と大変です。本番環境(もしくは近い形)でテストを行いましょう。cf, tailscale, ngrokなど。 tunnel: ${hash} credentials-file: ${path}.json ingress: - hostname: example.com service: http://localhost:4173 originRequest: noHappyEyeballs: true - service: http_status:404 # tunnel list, dnsに登録が必要です $ cloudflared tunnel list $ cloudflared tunnel --config cloudflared-config.yml run $ cloudflared tunnel route dns ${uuid} example.com 以下の2つのcollection recordを生成します。ユーザーにはai.syui.logが生成され、ここにコメントが記録されます。それを取得して表示しています。ai.syui.log.userは管理者であるVITE_ADMIN_DID用です。 VITE_COLLECTION_COMMENT=ai.syui.log VITE_COLLECTION_USER=ai.syui.log.user $ ailog auth login $ ailog stream server このコマンドでai.syui.logをjetstreamから監視して、書き込みがあれば、管理者のai.syui.log.userに記録され、そのuser-listに基づいて、コメント一覧を取得します。 つまり、コメント表示のアカウントを手動で設定するか、自動化するか。自動化するならserverでailog stream serverを動かさなければいけません。 ask-AI ask-AIの仕組みは割愛します。後に変更される可能性が高いと思います。 local llm, mcp, atprotoと組み合わせです。 code syntax # comment d=${0:a:h} // This is a comment fn main() { println!("Hello, world!"); } // This is a comment console.log("Hello, world!");", + "description": "rustで静的サイトジェネレータを作りました。ailogといいます。hugoからの移行になります。 \nailogは、最初にatproto-comment-system(oauth)とask-AIという機能をつけました。 \nquick start\n$ git clone https://git.syui.ai/ai/log\n$ cd log\n$ cargo build\n$ ./target/debu...", + "formated_time": "Thu Jun 12, 2025", + "href": "https://syui.ai/posts/2025-06-06-ailog.html", + "tags": [ + "blog", + "rust", + "mcp", + "atp" + ], + "title": "静的サイトジェネレータを作った", + "utc_time": "2025-06-12T00:00:00Z" + } +] \ No newline at end of file diff --git a/my-blog/templates/oauth-assets.html b/my-blog/templates/oauth-assets.html index 41ee2db..d6aef90 100644 --- a/my-blog/templates/oauth-assets.html +++ b/my-blog/templates/oauth-assets.html @@ -1,3 +1,3 @@ - - \ No newline at end of file + + \ No newline at end of file diff --git a/oauth/.env.production b/oauth/.env.production index fbe2758..6d6db79 100644 --- a/oauth/.env.production +++ b/oauth/.env.production @@ -4,9 +4,8 @@ VITE_OAUTH_CLIENT_ID=https://syui.ai/client-metadata.json VITE_OAUTH_REDIRECT_URI=https://syui.ai/oauth/callback VITE_ADMIN_DID=did:plc:uqzpqmrjnptsxezjx4xuh2mn -# Base collection for OAuth app and ailog (all others are derived) +# Base collection (all others are derived via getCollectionNames) VITE_OAUTH_COLLECTION=ai.syui.log -# [user, chat, chat.lang, chat.comment] # AI Configuration VITE_AI_ENABLED=true @@ -19,3 +18,4 @@ VITE_AI_DID=did:plc:4hqjfn7m6n5hno3doamuhgef # API Configuration VITE_BSKY_PUBLIC_API=https://public.api.bsky.app +VITE_ATPROTO_API=https://bsky.social diff --git a/oauth/src/App.css b/oauth/src/App.css index a20e199..95bb533 100644 --- a/oauth/src/App.css +++ b/oauth/src/App.css @@ -194,6 +194,7 @@ padding: 10px !important; word-wrap: break-word !important; overflow-wrap: break-word !important; + white-space: pre-wrap !important; } .comment-header { @@ -323,6 +324,7 @@ /* padding: 20px; - removed to avoid double padding */ } + .auth-section { background: #f8f9fa; border: 1px solid #e9ecef; @@ -610,6 +612,8 @@ line-height: 1.5; color: #333; margin-bottom: 10px; + white-space: pre-wrap; + word-wrap: break-word; } .comment-meta { diff --git a/oauth/src/App.tsx b/oauth/src/App.tsx index fb4c80b..b81c161 100644 --- a/oauth/src/App.tsx +++ b/oauth/src/App.tsx @@ -7,32 +7,10 @@ import { appConfig, getCollectionNames } from './config/app'; import './App.css'; function App() { - console.log('APP COMPONENT LOADED - Console working!'); - console.log('Current timestamp:', new Date().toISOString()); - - // Immediately log URL information on every page load - console.log('IMMEDIATE URL CHECK:'); - console.log('- href:', window.location.href); - console.log('- pathname:', window.location.pathname); - console.log('- search:', window.location.search); - console.log('- hash:', window.location.hash); - - // Also show URL info via alert if it contains OAuth parameters + // Handle OAuth callback detection if (window.location.search.includes('code=') || window.location.search.includes('state=')) { const urlInfo = `OAuth callback detected!\n\nURL: ${window.location.href}\nSearch: ${window.location.search}`; alert(urlInfo); - console.log('OAuth callback URL detected!'); - } else { - // Check if we have stored OAuth info from previous steps - const preOAuthUrl = sessionStorage.getItem('pre_oauth_url'); - const storedState = sessionStorage.getItem('oauth_state'); - const storedCodeVerifier = sessionStorage.getItem('oauth_code_verifier'); - - console.log('=== OAUTH SESSION STORAGE CHECK ==='); - console.log('Pre-OAuth URL:', preOAuthUrl); - console.log('Stored state:', storedState); - console.log('Stored code verifier:', storedCodeVerifier ? 'Present' : 'Missing'); - console.log('=== END SESSION STORAGE CHECK ==='); } const [user, setUser] = useState(null); @@ -59,7 +37,6 @@ function App() { const collections = getCollectionNames(appConfig.collections.base); ws.onopen = () => { - console.log('Jetstream connected'); ws.send(JSON.stringify({ wantedCollections: [collections.comment] })); @@ -69,22 +46,20 @@ function App() { try { const data = JSON.parse(event.data); if (data.collection === collections.comment && data.commit?.operation === 'create') { - console.log('New comment detected via Jetstream:', data); // Optionally reload comments // loadAllComments(window.location.href); } } catch (err) { - console.warn('Failed to parse Jetstream message:', err); + // Ignore parsing errors } }; ws.onerror = (err) => { - console.warn('Jetstream error:', err); + // Ignore Jetstream errors }; return ws; } catch (err) { - console.warn('Failed to setup Jetstream:', err); return null; } }; @@ -108,11 +83,11 @@ function App() { // キャッシュがなければ、ATProtoから取得(認証状態に関係なく) if (!loadCachedComments()) { - console.log('No cached comments found, loading from ATProto...'); loadAllComments(); // URLフィルタリングを無効にして全コメント表示 - } else { - console.log('Cached comments loaded successfully'); } + + // Load AI chat history (認証状態に関係なく、全ユーザーのチャット履歴を表示) + loadAiChatHistory(); // Handle popstate events for mock OAuth flow const handlePopState = () => { @@ -138,12 +113,9 @@ function App() { // Check existing sessions const checkAuth = async () => { // First check OAuth session using official BrowserOAuthClient - console.log('Checking OAuth session...'); const oauthResult = await atprotoOAuthService.checkSession(); - console.log('OAuth checkSession result:', oauthResult); if (oauthResult) { - console.log('OAuth session found:', oauthResult); // Ensure handle is not DID const handle = oauthResult.handle !== oauthResult.did ? oauthResult.handle : oauthResult.handle; @@ -153,11 +125,10 @@ function App() { // Load all comments for display (this will be the default view) // Temporarily disable URL filtering to see all comments - console.log('OAuth session found, loading all comments...'); loadAllComments(); // Load AI chat history - loadAiChatHistory(userProfile.did); + loadAiChatHistory(); // Load user list records if admin if (userProfile.did === appConfig.adminDid) { @@ -166,8 +137,6 @@ function App() { setIsLoading(false); return; - } else { - console.log('No OAuth session found'); } // Fallback to legacy auth @@ -177,7 +146,6 @@ function App() { // Load all comments for display (this will be the default view) // Temporarily disable URL filtering to see all comments - console.log('Legacy auth session found, loading all comments...'); loadAllComments(); // Load user list records if admin @@ -188,7 +156,6 @@ function App() { setIsLoading(false); // 認証状態に関係なく、コメントを読み込む - console.log('No auth session found, loading all comments anyway...'); loadAllComments(); }; @@ -215,7 +182,7 @@ function App() { }; } } catch (error) { - console.error('Failed to get user profile:', error); + // Failed to get user profile } // Fallback to basic user info @@ -236,28 +203,73 @@ function App() { return `data:image/svg+xml;base64,${btoa(svg)}`; }; - const loadAiChatHistory = async (did: string) => { + const loadAiChatHistory = async () => { try { - console.log('Loading AI chat history for DID:', did); - const agent = atprotoOAuthService.getAgent(); - if (!agent) { - console.log('No agent available'); + // 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); + + // First, get user list from admin + const userListResponse = await fetch(`${atprotoApi}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(adminDid)}&collection=${encodeURIComponent(collections.user)}&limit=100`); + + if (!userListResponse.ok) { + setAiChatHistory([]); return; } - - // Get AI chat records from current user - const response = await agent.api.com.atproto.repo.listRecords({ - repo: did, - collection: appConfig.collections.chat, - limit: 100, + + const userListData = await userListResponse.json(); + const userRecords = userListData.records || []; + + // Extract unique DIDs from user records (including admin DID for their own chats) + const allUserDids = []; + userRecords.forEach(record => { + if (record.value.users && Array.isArray(record.value.users)) { + record.value.users.forEach(user => { + if (user.did) { + allUserDids.push(user.did); + } + }); + } }); - - console.log('AI chat history loaded:', response.data); - const chatRecords = response.data.records || []; + + // Always include admin DID to check admin's own chats + allUserDids.push(adminDid); + + const userDids = [...new Set(allUserDids)]; + + // Load chat records from all registered users (including admin) + const allChatRecords = []; + for (const userDid of userDids) { + try { + const chatResponse = await fetch(`${atprotoApi}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(userDid)}&collection=${encodeURIComponent(collections.chat)}&limit=100`); + + if (chatResponse.ok) { + const chatData = await chatResponse.json(); + const records = chatData.records || []; + allChatRecords.push(...records); + } + } catch (err) { + continue; + } + } + + // Filter for page-specific content if on a post page + let filteredRecords = allChatRecords; + if (appConfig.rkey) { + // On post page: show only chats for this specific post + filteredRecords = allChatRecords.filter(record => { + const recordPath = record.value.post?.url ? new URL(record.value.post.url).pathname : ''; + return recordPath === window.location.pathname; + }); + } else { + // On top page: show latest 3 records from all pages + filteredRecords = allChatRecords.slice(0, 3); + } // Filter out old records with invalid AI profile data (temporary fix for migration) - const validRecords = chatRecords.filter(record => { - if (record.value.answer) { + const validRecords = filteredRecords.filter(record => { + if (record.value.type === 'answer') { // This is an AI answer - check if it has valid AI profile return record.value.author?.handle && record.value.author?.handle !== 'ai-assistant' && @@ -266,16 +278,13 @@ function App() { return true; // Keep all questions }); - console.log(`Filtered ${chatRecords.length} records to ${validRecords.length} valid records`); - - // Sort by creation time and group question-answer pairs + // Sort by creation time const sortedRecords = validRecords.sort((a, b) => - new Date(a.value.createdAt).getTime() - new Date(b.value.createdAt).getTime() + new Date(b.value.createdAt).getTime() - new Date(a.value.createdAt).getTime() ); setAiChatHistory(sortedRecords); } catch (err) { - console.error('Failed to load AI chat history:', err); setAiChatHistory([]); } }; @@ -284,58 +293,64 @@ function App() { const loadAIGeneratedContent = async () => { try { const adminDid = appConfig.adminDid; - const bskyApi = appConfig.bskyPublicApi || 'https://public.api.bsky.app'; + const atprotoApi = appConfig.atprotoApi || 'https://bsky.social'; const collections = getCollectionNames(appConfig.collections.base); // Load lang:en records - const langResponse = await fetch(`${bskyApi}/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(adminDid)}&collection=${encodeURIComponent(collections.chatLang)}&limit=100`); if (langResponse.ok) { const langData = await langResponse.json(); const langRecords = langData.records || []; - // Filter by current page URL if on post page + // Filter by current page path if on post page const filteredLangRecords = appConfig.rkey - ? langRecords.filter(record => record.value.url === window.location.href) + ? langRecords.filter(record => { + // Compare path only, not full URL to support localhost vs production + const recordPath = record.value.post?.url ? new URL(record.value.post.url).pathname : + record.value.url ? new URL(record.value.url).pathname : ''; + return recordPath === window.location.pathname; + }) : langRecords.slice(0, 3); // Top page: latest 3 setLangEnRecords(filteredLangRecords); } // Load AI comment records - const commentResponse = await fetch(`${bskyApi}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(adminDid)}&collection=${encodeURIComponent(collections.chatComment)}&limit=100`); + const commentResponse = await fetch(`${atprotoApi}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(adminDid)}&collection=${encodeURIComponent(collections.chatComment)}&limit=100`); if (commentResponse.ok) { const commentData = await commentResponse.json(); const commentRecords = commentData.records || []; - // Filter by current page URL if on post page + // Filter by current page path if on post page const filteredCommentRecords = appConfig.rkey - ? commentRecords.filter(record => record.value.url === window.location.href) + ? commentRecords.filter(record => { + // Compare path only, not full URL to support localhost vs production + const recordPath = record.value.post?.url ? new URL(record.value.post.url).pathname : + record.value.url ? new URL(record.value.url).pathname : ''; + return recordPath === window.location.pathname; + }) : commentRecords.slice(0, 3); // Top page: latest 3 setAiCommentRecords(filteredCommentRecords); } } catch (err) { - console.error('Failed to load AI generated content:', err); + // Ignore errors } }; const loadUserComments = async (did: string) => { try { - console.log('Loading comments for DID:', did); const agent = atprotoOAuthService.getAgent(); if (!agent) { - console.log('No agent available'); return; } // Get comments from current user const response = await agent.api.com.atproto.repo.listRecords({ repo: did, - collection: appConfig.collections.comment, + collection: getCollectionNames(appConfig.collections.base).comment, limit: 100, }); - - console.log('User comments loaded:', response.data); const userComments = response.data.records || []; // Enhance comments with profile information if missing @@ -356,7 +371,7 @@ function App() { } }; } catch (err) { - console.warn('Failed to enhance comment with profile:', err); + // Ignore enhancement errors return record; } } @@ -366,7 +381,7 @@ function App() { setComments(enhancedComments); } catch (err) { - console.error('Failed to load comments:', err); + // Ignore load errors setComments([]); } }; @@ -376,20 +391,20 @@ function App() { try { // 管理者のユーザーリストを取得 const adminDid = appConfig.adminDid; - console.log('Fetching user list from admin DID:', adminDid); - const response = await fetch(`https://bsky.social/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(adminDid)}&collection=${encodeURIComponent(appConfig.collections.user)}&limit=100`); + // Fetching user list from admin DID + const response = await fetch(`https://bsky.social/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(adminDid)}&collection=${encodeURIComponent(getCollectionNames(appConfig.collections.base).user)}&limit=100`); if (!response.ok) { - console.warn('Failed to fetch user list from admin, using default users. Status:', response.status); + // Failed to fetch user list from admin, using default users return getDefaultUsers(); } const data = await response.json(); const userRecords = data.records || []; - console.log('User records found:', userRecords.length); + // User records found if (userRecords.length === 0) { - console.log('No user records found, using default users'); + // No user records found, using default users return getDefaultUsers(); } @@ -401,13 +416,13 @@ function App() { const resolvedUsers = await Promise.all( record.value.users.map(async (user) => { if (user.did && user.did.includes('-placeholder')) { - console.log(`Resolving placeholder DID for ${user.handle}`); + // Resolving placeholder DID try { const profileResponse = await fetch(`${appConfig.bskyPublicApi}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(user.handle)}`); if (profileResponse.ok) { const profileData = await profileResponse.json(); if (profileData.did) { - console.log(`Resolved ${user.handle}: ${user.did} -> ${profileData.did}`); + // Resolved DID return { ...user, did: profileData.did @@ -415,7 +430,7 @@ function App() { } } } catch (err) { - console.warn(`Failed to resolve DID for ${user.handle}:`, err); + // Failed to resolve DID } } return user; @@ -425,10 +440,10 @@ function App() { } } - console.log('Loaded and resolved users from admin records:', allUsers); + // Loaded and resolved users from admin records return allUsers; } catch (err) { - console.warn('Failed to load users from records, using defaults:', err); + // Failed to load users from records, using defaults return getDefaultUsers(); } }; @@ -436,12 +451,12 @@ function App() { // ユーザーリスト一覧を読み込み const loadUserListRecords = async () => { try { - console.log('Loading user list records...'); + // Loading user list records const adminDid = appConfig.adminDid; - const response = await fetch(`https://bsky.social/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(adminDid)}&collection=${encodeURIComponent(appConfig.collections.user)}&limit=100`); + const response = await fetch(`https://bsky.social/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(adminDid)}&collection=${encodeURIComponent(getCollectionNames(appConfig.collections.base).user)}&limit=100`); if (!response.ok) { - console.warn('Failed to fetch user list records'); + // Failed to fetch user list records setUserListRecords([]); return; } @@ -454,10 +469,10 @@ function App() { new Date(b.value.createdAt).getTime() - new Date(a.value.createdAt).getTime() ); - console.log(`Loaded ${sortedRecords.length} user list records`); + // Loaded user list records setUserListRecords(sortedRecords); } catch (err) { - console.error('Failed to load user list records:', err); + // Failed to load user list records setUserListRecords([]); } }; @@ -477,39 +492,33 @@ function App() { }); } - console.log('Default users list (including current user):', defaultUsers); + // Default users list (including current user) return defaultUsers; }; // 新しい関数: 全ユーザーからコメントを収集 const loadAllComments = async (pageUrl?: string) => { try { - console.log('Loading comments from all users...'); - console.log('Page URL filter:', pageUrl); // ユーザーリストを動的に取得 const knownUsers = await loadUsersFromRecord(); - console.log('Known users for comment fetching:', knownUsers); const allComments = []; // 各ユーザーからコメントを収集 for (const user of knownUsers) { try { - console.log(`Fetching comments from user: ${user.handle} (${user.did}) at ${user.pds}`); // Public API使用(認証不要) const collections = getCollectionNames(appConfig.collections.base); const response = await fetch(`${user.pds}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(user.did)}&collection=${encodeURIComponent(collections.comment)}&limit=100`); if (!response.ok) { - console.warn(`Failed to fetch from ${user.handle} (${response.status}): ${response.statusText}`); continue; } const data = await response.json(); const userRecords = data.records || []; - console.log(`Found ${userRecords.length} comment records from ${user.handle}`); // Flatten comments from new array format const userComments = []; @@ -529,18 +538,24 @@ function App() { } } - console.log(`Flattened to ${userComments.length} individual comments from ${user.handle}`); - // ページURLでフィルタリング(指定された場合) + // ページpathでフィルタリング(指定された場合) const filteredComments = pageUrl - ? userComments.filter(record => record.value.url === pageUrl) + ? userComments.filter(record => { + try { + // Compare path only, not full URL to support localhost vs production + const recordPath = record.value.url ? new URL(record.value.url).pathname : ''; + const currentPath = new URL(pageUrl).pathname; + return recordPath === currentPath; + } catch (err) { + // Fallback to exact match if URL parsing fails + return record.value.url === pageUrl; + } + }) : userComments; - console.log(`After URL filtering (${pageUrl}): ${filteredComments.length} comments from ${user.handle}`); - console.log('All comments from this user:', userComments.map(r => ({ url: r.value.url, text: r.value.text }))); allComments.push(...filteredComments); } catch (err) { - console.warn(`Failed to load comments from ${user.handle}:`, err); } } @@ -572,21 +587,17 @@ function App() { }; } } catch (err) { - console.warn('Failed to enhance comment with profile:', err); + // Ignore enhancement errors } } return record; }) ); - console.log(`Loaded ${enhancedComments.length} comments from all users`); // デバッグ情報を追加 - console.log('Final enhanced comments:', enhancedComments); - console.log('Known users used:', knownUsers); setComments(enhancedComments); - console.log('Comments state updated with', enhancedComments.length, 'comments'); // キャッシュに保存(5分間有効) if (pageUrl) { @@ -598,7 +609,6 @@ function App() { localStorage.setItem(cacheKey, JSON.stringify(cacheData)); } } catch (err) { - console.error('Failed to load all comments:', err); setComments([]); } }; @@ -640,7 +650,7 @@ function App() { try { const existingResponse = await agent.api.com.atproto.repo.getRecord({ repo: user.did, - collection: appConfig.collections.comment, + collection: getCollectionNames(appConfig.collections.base).comment, rkey: rkey, }); @@ -659,7 +669,6 @@ function App() { } } catch (err) { // Record doesn't exist yet, that's fine - console.log('No existing record found, creating new one'); } // Add new comment to the array @@ -667,7 +676,7 @@ function App() { // Create the record with comments array const record = { - $type: appConfig.collections.comment, + $type: getCollectionNames(appConfig.collections.base).comment, comments: existingComments, url: window.location.href, createdAt: now.toISOString(), // Latest update time @@ -676,18 +685,16 @@ function App() { // Post to ATProto with rkey const response = await agent.api.com.atproto.repo.putRecord({ repo: user.did, - collection: appConfig.collections.comment, + collection: getCollectionNames(appConfig.collections.base).comment, rkey: rkey, record: record, }); - console.log('Comment posted:', response); // Clear form and reload all comments setCommentText(''); await loadAllComments(window.location.href); } catch (err: any) { - console.error('Failed to post comment:', err); setError('コメントの投稿に失敗しました: ' + err.message); } finally { setIsPosting(false); @@ -714,22 +721,19 @@ function App() { const uriParts = uri.split('/'); const rkey = uriParts[uriParts.length - 1]; - console.log('Deleting comment with rkey:', rkey); // Delete the record await agent.api.com.atproto.repo.deleteRecord({ repo: user.did, - collection: appConfig.collections.comment, + collection: getCollectionNames(appConfig.collections.base).comment, rkey: rkey, }); - console.log('Comment deleted successfully'); // Reload all comments to reflect the deletion await loadAllComments(window.location.href); } catch (err: any) { - console.error('Failed to delete comment:', err); alert('コメントの削除に失敗しました: ' + err.message); } }; @@ -787,11 +791,9 @@ function App() { const profileData = await profileResponse.json(); if (profileData.did) { resolvedDid = profileData.did; - console.log(`Resolved ${handle} -> ${resolvedDid}`); } } } catch (err) { - console.warn(`Failed to resolve DID for ${handle}:`, err); } return { @@ -806,7 +808,7 @@ function App() { const rkey = now.toISOString().replace(/[:.]/g, '-'); const record = { - $type: appConfig.collections.user, + $type: getCollectionNames(appConfig.collections.base).user, users: users, createdAt: now.toISOString(), updatedBy: { @@ -818,19 +820,17 @@ function App() { // Post to ATProto with rkey const response = await agent.api.com.atproto.repo.putRecord({ repo: user.did, - collection: appConfig.collections.user, + collection: getCollectionNames(appConfig.collections.base).user, rkey: rkey, record: record, }); - console.log('User list posted:', response); // Clear form and reload user list records setUserListInput(''); loadUserListRecords(); alert('ユーザーリストが更新されました'); } catch (err: any) { - console.error('Failed to post user list:', err); setError('ユーザーリストの投稿に失敗しました: ' + err.message); } finally { setIsPostingUserList(false); @@ -858,21 +858,18 @@ function App() { const uriParts = uri.split('/'); const rkey = uriParts[uriParts.length - 1]; - console.log('Deleting user list with rkey:', rkey); // Delete the record await agent.api.com.atproto.repo.deleteRecord({ repo: user.did, - collection: appConfig.collections.user, + collection: getCollectionNames(appConfig.collections.base).user, rkey: rkey, }); - console.log('User list deleted successfully'); loadUserListRecords(); alert('ユーザーリストが削除されました'); } catch (err: any) { - console.error('Failed to delete user list:', err); alert('ユーザーリストの削除に失敗しました: ' + err.message); } }; @@ -895,7 +892,6 @@ function App() { try { await atprotoOAuthService.initiateOAuthFlow(handleInput); } catch (err) { - console.error('OAuth failed:', err); alert('認証の開始に失敗しました。再度お試しください。'); } }; @@ -918,7 +914,13 @@ function App() { } // Extract rkey from comment URI: at://did:plc:xxx/collection/rkey - const uriParts = record.uri.split('/'); + // Handle both original records and flattened records from new array format + const uri = record.uri || record.originalRecord?.uri; + if (!uri) { + return false; + } + + const uriParts = uri.split('/'); const commentRkey = uriParts[uriParts.length - 1]; // Show comment only if rkey matches current post @@ -926,12 +928,113 @@ function App() { }; // OAuth callback is now handled by React Router in main.tsx - console.log('=== APP.TSX URL CHECK ==='); - console.log('Full URL:', window.location.href); - console.log('Pathname:', window.location.pathname); - console.log('Search params:', window.location.search); - console.log('=== END URL CHECK ==='); + // Unified rendering function for AI content + const renderAIContent = (record: any, index: number, className: string) => { + // Handle both new format (record.value.$type) and old format compatibility + const value = record.value; + const isNewFormat = value.$type && value.post && value.author; + + // Extract content based on format + const contentText = isNewFormat ? value.text : (value.content || value.body || ''); + const authorInfo = isNewFormat ? value.author : null; + const postInfo = isNewFormat ? value.post : null; + const contentType = value.type || 'unknown'; + const createdAt = value.createdAt || value.generated_at || ''; + + return ( +
+
+ AI Avatar { + // 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 + }); + } + }} + /> +
+ + {authorInfo?.displayName || 'AI'} + + + @{authorInfo?.handle || 'ai'} + +
+ + {new Date(createdAt).toLocaleString()} + +
+ +
+
+ +
+ {(postInfo?.url || value.post_url) && ( + + + {postInfo?.url || value.post_url} + + + )} +
+ + {/* JSON Display */} + {showJsonFor === record.uri && ( +
+
JSON Record:
+
+              {JSON.stringify(record, null, 2)}
+            
+
+ )} + +
+ {contentText?.split('\n').map((line: string, index: number) => ( + + {line} + {index < contentText.split('\n').length - 1 &&
} +
+ ))} +
+
+ ); + }; + + const getTypeLabel = (collectionType: string, contentType: string) => { + if (!collectionType) return contentType; + + const collections = getCollectionNames(appConfig.collections.base); + + if (collectionType === collections.chat) { + return contentType === 'question' ? '質問' : '回答'; + } + if (collectionType === collections.chatLang) { + return `翻訳: ${contentType.toUpperCase()}`; + } + if (collectionType === collections.chatComment) { + return `AI ${contentType}`; + } + return contentType; + }; return (
@@ -1081,14 +1184,12 @@ function App() { > Comments ({comments.filter(shouldShowComment).length}) - {user && ( - - )} +
-
- {record.value.text} -
+
{record.value.url && ( {record.value.url} @@ -1189,6 +1287,15 @@ function App() {
)} + +
+ {record.value.text?.split('\n').map((line: string, index: number) => ( + + {line} + {index < record.value.text.split('\n').length - 1 &&
} +
+ ))} +
)) )} @@ -1196,7 +1303,7 @@ function App() { )} {/* AI Chat History List */} - {activeTab === 'ai-chat' && user && ( + {activeTab === 'ai-chat' && (

AI Chat History

@@ -1204,43 +1311,49 @@ function App() { {aiChatHistory.length === 0 ? (

No AI conversations yet. Start chatting with Ask AI!

) : ( - aiChatHistory.map((record, index) => ( -
-
- User Avatar { - // Fetch fresh avatar from API when component mounts - if (img && record.value.author?.did) { - fetch(`${appConfig.bskyPublicApi}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(record.value.author.did)}`) - .then(res => res.json()) - .then(data => { - if (data.avatar && img) { - img.src = data.avatar; - } - }) - .catch(err => { - console.warn('Failed to fetch fresh avatar:', err); - // Keep placeholder on error - }); - } - }} - /> -
- - {record.value.author?.displayName || record.value.author?.handle || 'unknown'} - - - @{record.value.author?.handle || 'unknown'} - -
+ 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 ? 'ai.syui' : record.value.author?.handle; + const displayName = isAiResponse ? 'AI' : (record.value.author?.displayName || record.value.author?.handle); + + return ( +
+
+ {isAiResponse { + // 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 + }); + } + }} + /> +
+ + {displayName || 'unknown'} + + + @{displayHandle || 'unknown'} + +
{new Date(record.value.createdAt).toLocaleString()} @@ -1253,16 +1366,14 @@ function App() { {showJsonFor === record.uri ? 'Hide' : 'JSON'}
-
- {record.value.question || record.value.answer} -
+
- {record.value.url && ( - {record.value.url} + {record.value.post?.url && ( + {record.value.post.url} )}
@@ -1275,8 +1386,18 @@ function App() {
)} + +
+ {record.value.text?.split('\n').map((line: string, index: number) => ( + + {line} + {index < record.value.text.split('\n').length - 1 &&
} +
+ ))} +
- )) + ); + }) )}
)} @@ -1287,76 +1408,88 @@ function App() { {langEnRecords.length === 0 ? (

No English translations yet

) : ( - langEnRecords.map((record, index) => ( -
-
- AI Avatar -
- - {record.value.author?.displayName || 'AI Translator'} - - - @{record.value.author?.handle || 'ai'} - -
- - {new Date(record.value.createdAt).toLocaleString()} - -
-
-
Type: {record.value.type || 'en'}
-
{record.value.body}
-
-
- {record.value.url && ( - {record.value.url} - )} -
-
- )) + langEnRecords.map((record, index) => + renderAIContent(record, index, 'lang-item') + ) )}
)} {/* AI Comment List */} {activeTab === 'ai-comment' && ( -
+
{aiCommentRecords.length === 0 ? (

No AI comments yet

) : ( aiCommentRecords.map((record, index) => ( -
-
+
+
AI Avatar { + // 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 + }); + } + }} />
- {record.value.author?.displayName || 'AI Commenter'} + AI - @{record.value.author?.handle || 'ai'} + @ai
- {new Date(record.value.createdAt).toLocaleString()} + {new Date(record.value.createdAt || record.value.generated_at).toLocaleString()} +
+ +
-
-
Type: {record.value.type || 'comment'}
-
{record.value.body}
-
+
- {record.value.url && ( - {record.value.url} + {(record.value.post?.url || record.value.post_url) && ( + {record.value.post?.url || record.value.post_url} )}
+ + {/* JSON Display */} + {showJsonFor === record.uri && ( +
+
JSON Record:
+
+                          {JSON.stringify(record, null, 2)}
+                        
+
+ )} + +
+ {(record.value.text || record.value.comment)?.split('\n').map((line: string, index: number) => ( + + {line} + {index < (record.value.text || record.value.comment)?.split('\n').length - 1 &&
} +
+ ))} +
)) )} diff --git a/oauth/src/components/AIChat.tsx b/oauth/src/components/AIChat.tsx index ffceb0b..8ebce70 100644 --- a/oauth/src/components/AIChat.tsx +++ b/oauth/src/components/AIChat.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; import { User } from '../services/auth'; import { atprotoOAuthService } from '../services/atproto-oauth'; -import { appConfig } from '../config/app'; +import { appConfig, getCollectionNames } from '../config/app'; interface AIChatProps { user: User | null; @@ -14,26 +14,22 @@ export const AIChat: React.FC = ({ user, isEnabled }) => { const [isProcessing, setIsProcessing] = useState(false); const [aiProfile, setAiProfile] = useState(null); - // Get AI settings from environment variables + // Get AI settings from appConfig (unified configuration) const aiConfig = { - enabled: import.meta.env.VITE_AI_ENABLED === 'true', - askAi: import.meta.env.VITE_AI_ASK_AI === 'true', - provider: import.meta.env.VITE_AI_PROVIDER || 'ollama', - model: import.meta.env.VITE_AI_MODEL || 'gemma3:4b', - host: import.meta.env.VITE_AI_HOST || 'https://ollama.syui.ai', - systemPrompt: import.meta.env.VITE_AI_SYSTEM_PROMPT || 'You are a helpful AI assistant trained on this blog\'s content.', - aiDid: import.meta.env.VITE_AI_DID || 'did:plc:uqzpqmrjnptsxezjx4xuh2mn', - bskyPublicApi: import.meta.env.VITE_BSKY_PUBLIC_API || 'https://public.api.bsky.app', + enabled: appConfig.aiEnabled, + askAi: appConfig.aiAskAi, + provider: appConfig.aiProvider, + model: appConfig.aiModel, + host: appConfig.aiHost, + systemPrompt: appConfig.aiSystemPrompt, + aiDid: appConfig.aiDid, + bskyPublicApi: appConfig.bskyPublicApi, }; // Fetch AI profile on load useEffect(() => { const fetchAIProfile = async () => { - console.log('=== AI PROFILE FETCH START ==='); - console.log('AI DID:', aiConfig.aiDid); - if (!aiConfig.aiDid) { - console.log('No AI DID configured'); return; } @@ -41,9 +37,7 @@ export const AIChat: React.FC = ({ user, isEnabled }) => { // Try with agent first const agent = atprotoOAuthService.getAgent(); if (agent) { - console.log('Fetching AI profile with agent for DID:', aiConfig.aiDid); const profile = await agent.getProfile({ actor: aiConfig.aiDid }); - console.log('AI profile fetched successfully:', profile.data); const profileData = { did: aiConfig.aiDid, handle: profile.data.handle, @@ -51,21 +45,17 @@ export const AIChat: React.FC = ({ user, isEnabled }) => { avatar: profile.data.avatar, description: profile.data.description }; - console.log('Setting aiProfile to:', profileData); setAiProfile(profileData); // Dispatch event to update Ask AI button window.dispatchEvent(new CustomEvent('aiProfileLoaded', { detail: profileData })); - console.log('=== AI PROFILE FETCH SUCCESS (AGENT) ==='); return; } // Fallback to public API - console.log('No agent available, trying public API for AI profile'); const response = await fetch(`${aiConfig.bskyPublicApi}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(aiConfig.aiDid)}`); if (response.ok) { const profileData = await response.json(); - console.log('AI profile fetched via public API:', profileData); const profile = { did: aiConfig.aiDid, handle: profileData.handle, @@ -73,21 +63,15 @@ export const AIChat: React.FC = ({ user, isEnabled }) => { avatar: profileData.avatar, description: profileData.description }; - console.log('Setting aiProfile to:', profile); setAiProfile(profile); // Dispatch event to update Ask AI button window.dispatchEvent(new CustomEvent('aiProfileLoaded', { detail: profile })); - console.log('=== AI PROFILE FETCH SUCCESS (PUBLIC API) ==='); return; - } else { - console.error('Public API failed with status:', response.status); } } catch (error) { - console.error('Failed to fetch AI profile:', error); setAiProfile(null); } - console.log('=== AI PROFILE FETCH FAILED ==='); }; fetchAIProfile(); @@ -100,9 +84,6 @@ export const AIChat: React.FC = ({ user, isEnabled }) => { const handleAIQuestion = async (event: any) => { if (!user || !event.detail || !event.detail.question || isProcessing || !aiProfile) return; - console.log('AIChat received question:', event.detail.question); - console.log('Current aiProfile state:', aiProfile); - setIsProcessing(true); try { await postQuestionAndGenerateResponse(event.detail.question); @@ -114,7 +95,6 @@ export const AIChat: React.FC = ({ user, isEnabled }) => { // Add listener with a small delay to ensure it's ready setTimeout(() => { window.addEventListener('postAIQuestion', handleAIQuestion); - console.log('AIChat event listener registered'); // Notify that AI is ready window.dispatchEvent(new CustomEvent('aiChatReady')); @@ -134,40 +114,50 @@ export const AIChat: React.FC = ({ user, isEnabled }) => { const agent = atprotoOAuthService.getAgent(); if (!agent) throw new Error('No agent available'); + // Get collection names + const collections = getCollectionNames(appConfig.collections.base); + // 1. Post question to ATProto const now = new Date(); const rkey = now.toISOString().replace(/[:.]/g, '-'); + // Extract post metadata from current page + const currentUrl = window.location.href; + const postSlug = currentUrl.match(/\/posts\/([^/]+)/)?.[1] || ''; + const postTitle = document.title.replace(' - syui.ai', '') || ''; + const questionRecord = { - $type: appConfig.collections.chat, - question: question, - url: window.location.href, - createdAt: now.toISOString(), + $type: collections.chat, + post: { + url: currentUrl, + slug: postSlug, + title: postTitle, + date: new Date().toISOString(), + tags: [], + language: "ja" + }, + type: "question", + text: question, author: { did: user.did, handle: user.handle, avatar: user.avatar, displayName: user.displayName || user.handle, }, - context: { - page_title: document.title, - page_url: window.location.href, - }, + createdAt: now.toISOString(), }; await agent.api.com.atproto.repo.putRecord({ repo: user.did, - collection: appConfig.collections.chat, + collection: collections.chat, rkey: rkey, record: questionRecord, }); - console.log('Question posted to ATProto'); - // 2. Get chat history const chatRecords = await agent.api.com.atproto.repo.listRecords({ repo: user.did, - collection: appConfig.collections.chat, + collection: collections.chat, limit: 10, }); @@ -175,10 +165,10 @@ export const AIChat: React.FC = ({ user, isEnabled }) => { if (chatRecords.data.records) { chatHistoryText = chatRecords.data.records .map((r: any) => { - if (r.value.question) { - return `User: ${r.value.question}`; - } else if (r.value.answer) { - return `AI: ${r.value.answer}`; + if (r.value.type === 'question') { + return `User: ${r.value.text}`; + } else if (r.value.type === 'answer') { + return `AI: ${r.value.text}`; } return ''; }) @@ -235,37 +225,38 @@ Answer:`; // 5. Save AI response in background const answerRkey = now.toISOString().replace(/[:.]/g, '-') + '-answer'; - console.log('=== SAVING AI ANSWER ==='); - console.log('Current aiProfile:', aiProfile); - const answerRecord = { - $type: appConfig.collections.chat, - answer: aiAnswer, - question_rkey: rkey, - url: window.location.href, - createdAt: now.toISOString(), + $type: collections.chat, + post: { + url: currentUrl, + slug: postSlug, + title: postTitle, + date: new Date().toISOString(), + tags: [], + language: "ja" + }, + type: "answer", + text: aiAnswer, author: { did: aiProfile.did, handle: aiProfile.handle, displayName: aiProfile.displayName, avatar: aiProfile.avatar, }, + createdAt: now.toISOString(), }; - - console.log('Answer record to save:', answerRecord); // Save to ATProto asynchronously (don't wait for it) agent.api.com.atproto.repo.putRecord({ repo: user.did, - collection: appConfig.collections.chat, + collection: collections.chat, rkey: answerRkey, record: answerRecord, }).catch(err => { - console.error('Failed to save AI response to ATProto:', err); + // Silent fail for AI response saving }); } catch (error) { - console.error('Failed to generate AI response:', error); window.dispatchEvent(new CustomEvent('aiResponseError', { detail: { error: 'AI応答の生成に失敗しました' } })); diff --git a/oauth/src/config/app.ts b/oauth/src/config/app.ts index 5779f30..7c9dc4b 100644 --- a/oauth/src/config/app.ts +++ b/oauth/src/config/app.ts @@ -1,6 +1,7 @@ // Application configuration export interface AppConfig { adminDid: string; + aiDid: string; collections: { base: string; // Base collection like "ai.syui.log" }; @@ -11,18 +12,27 @@ export interface AppConfig { aiProvider: string; aiModel: string; aiHost: string; + aiSystemPrompt: string; bskyPublicApi: string; + atprotoApi: string; } // Collection name builders (similar to Rust implementation) export function getCollectionNames(base: string) { - return { + if (!base) { + // Fallback to default + base = 'ai.syui.log'; + } + + const collections = { comment: base, user: `${base}.user`, chat: `${base}.chat`, chatLang: `${base}.chat.lang`, chatComment: `${base}.chat.comment`, }; + + return collections; } // Generate collection names from host @@ -43,9 +53,9 @@ function generateBaseCollectionFromHost(host: string): string { // Reverse the parts for collection naming // log.syui.ai -> ai.syui.log const reversedParts = parts.reverse(); - return reversedParts.join('.'); + const result = reversedParts.join('.'); + return result; } catch (error) { - console.warn('Failed to generate collection base from host:', host, error); // Fallback to default return 'ai.syui.log'; } @@ -63,11 +73,19 @@ function extractRkeyFromUrl(): string | undefined { export function getAppConfig(): AppConfig { const host = import.meta.env.VITE_APP_HOST || 'https://log.syui.ai'; const adminDid = import.meta.env.VITE_ADMIN_DID || 'did:plc:uqzpqmrjnptsxezjx4xuh2mn'; + const aiDid = import.meta.env.VITE_AI_DID || 'did:plc:4hqjfn7m6n5hno3doamuhgef'; // Priority: Environment variables > Auto-generated from host const autoGeneratedBase = generateBaseCollectionFromHost(host); + let baseCollection = import.meta.env.VITE_OAUTH_COLLECTION || autoGeneratedBase; + + // Ensure base collection is never undefined + if (!baseCollection) { + baseCollection = 'ai.syui.log'; + } + const collections = { - base: import.meta.env.VITE_OAUTH_COLLECTION || autoGeneratedBase, + base: baseCollection, }; const rkey = extractRkeyFromUrl(); @@ -78,19 +96,14 @@ export function getAppConfig(): AppConfig { const aiProvider = import.meta.env.VITE_AI_PROVIDER || 'ollama'; const aiModel = import.meta.env.VITE_AI_MODEL || 'gemma2:2b'; const aiHost = import.meta.env.VITE_AI_HOST || 'https://ollama.syui.ai'; + const aiSystemPrompt = import.meta.env.VITE_AI_SYSTEM_PROMPT || 'You are a helpful AI assistant trained on this blog\'s content.'; const bskyPublicApi = import.meta.env.VITE_BSKY_PUBLIC_API || 'https://public.api.bsky.app'; + const atprotoApi = import.meta.env.VITE_ATPROTO_API || 'https://bsky.social'; - console.log('App configuration:', { - host, - adminDid, - collections, - rkey: rkey || 'none (not on post page)', - ai: { enabled: aiEnabled, askAi: aiAskAi, provider: aiProvider, model: aiModel, host: aiHost }, - bskyPublicApi - }); return { adminDid, + aiDid, collections, host, rkey, @@ -99,7 +112,9 @@ export function getAppConfig(): AppConfig { aiProvider, aiModel, aiHost, - bskyPublicApi + aiSystemPrompt, + bskyPublicApi, + atprotoApi }; } diff --git a/oauth/src/services/api.ts b/oauth/src/services/api.ts index 778a25a..3c2d48d 100644 --- a/oauth/src/services/api.ts +++ b/oauth/src/services/api.ts @@ -73,7 +73,6 @@ export const aiCardApi = { }); return response.data.data; } catch (error) { - console.warn('ai.gpt AI分析機能が利用できません:', error); throw new Error('AI分析機能を利用するにはai.gptサーバーが必要です'); } }, @@ -86,7 +85,6 @@ export const aiCardApi = { const response = await aiGptApi.get('/card_get_gacha_stats'); return response.data.data; } catch (error) { - console.warn('ai.gpt AI統計機能が利用できません:', error); throw new Error('AI統計機能を利用するにはai.gptサーバーが必要です'); } }, diff --git a/oauth/src/services/atproto-oauth.ts b/oauth/src/services/atproto-oauth.ts index e4851bf..f5b71ab 100644 --- a/oauth/src/services/atproto-oauth.ts +++ b/oauth/src/services/atproto-oauth.ts @@ -31,11 +31,11 @@ class AtprotoOAuthService { private async _doInitialize(): Promise { try { - console.log('=== INITIALIZING ATPROTO OAUTH CLIENT ==='); + // Generate client ID based on current origin const clientId = this.getClientId(); - console.log('Client ID:', clientId); + // Support multiple PDS hosts for OAuth this.oauthClient = await BrowserOAuthClient.load({ @@ -43,39 +43,33 @@ class AtprotoOAuthService { handleResolver: 'https://bsky.social', // Default resolver }); - console.log('BrowserOAuthClient initialized successfully with multi-PDS support'); + // Try to restore existing session const result = await this.oauthClient.init(); if (result?.session) { - console.log('Existing session restored:', { - did: result.session.did, - handle: result.session.handle || 'unknown', - hasAccessJwt: !!result.session.accessJwt, - hasRefreshJwt: !!result.session.refreshJwt - }); // Create Agent instance with proper configuration - console.log('Creating Agent with session:', result.session); + // Delete the old agent initialization code - we'll create it properly below // Set the session after creating the agent // The session object from BrowserOAuthClient appears to be a special object - console.log('Full session object:', result.session); - console.log('Session type:', typeof result.session); - console.log('Session constructor:', result.session?.constructor?.name); + + + // Try to iterate over the session object if (result.session) { - console.log('Session properties:'); + for (const key in result.session) { - console.log(` ${key}:`, result.session[key]); + } // Check if session has methods const methods = Object.getOwnPropertyNames(Object.getPrototypeOf(result.session)); - console.log('Session methods:', methods); + } // BrowserOAuthClient might return a Session object that needs to be used with the agent @@ -83,36 +77,36 @@ class AtprotoOAuthService { if (result.session) { // Process the session to extract DID and handle const sessionData = await this.processSession(result.session); - console.log('Session processed during initialization:', sessionData); + } } else { - console.log('No existing session found'); + } } catch (error) { - console.error('Failed to initialize OAuth client:', error); + this.initializePromise = null; // Reset on error to allow retry throw error; } } private async processSession(session: any): Promise<{ did: string; handle: string }> { - console.log('Processing session:', session); + // Log full session structure - console.log('Session structure:'); - console.log('- sub:', session.sub); - console.log('- did:', session.did); - console.log('- handle:', session.handle); - console.log('- iss:', session.iss); - console.log('- aud:', session.aud); + + + + + + // Check if agent has properties we can access if (session.agent) { - console.log('- agent:', session.agent); - console.log('- agent.did:', session.agent?.did); - console.log('- agent.handle:', session.agent?.handle); + + + } const did = session.sub || session.did; @@ -121,18 +115,18 @@ class AtprotoOAuthService { // Create Agent directly with session (per official docs) try { this.agent = new Agent(session); - console.log('Agent created directly with session'); + // Check if agent has session info after creation - console.log('Agent after creation:'); - console.log('- agent.did:', this.agent.did); - console.log('- agent.session:', this.agent.session); + + + if (this.agent.session) { - console.log('- agent.session.did:', this.agent.session.did); - console.log('- agent.session.handle:', this.agent.session.handle); + + } } catch (err) { - console.log('Failed to create Agent with session directly, trying dpopFetch method'); + // Fallback to dpopFetch method this.agent = new Agent({ service: session.server?.serviceEndpoint || 'https://bsky.social', @@ -145,7 +139,7 @@ class AtprotoOAuthService { // If handle is missing, try multiple methods to resolve it if (!handle || handle === 'unknown') { - console.log('Handle not in session, attempting to resolve...'); + // Method 1: Try using the agent to get profile try { @@ -154,11 +148,11 @@ class AtprotoOAuthService { if (profile.data.handle) { handle = profile.data.handle; (this as any)._sessionInfo.handle = handle; - console.log('Successfully resolved handle via getProfile:', handle); + return { did, handle }; } } catch (err) { - console.error('getProfile failed:', err); + } // Method 2: Try using describeRepo @@ -169,18 +163,20 @@ class AtprotoOAuthService { if (repoDesc.data.handle) { handle = repoDesc.data.handle; (this as any)._sessionInfo.handle = handle; - console.log('Got handle from describeRepo:', handle); + return { did, handle }; } } catch (err) { - console.error('describeRepo failed:', err); + } - // Method 3: Hardcoded fallback for known DIDs - if (did === 'did:plc:uqzpqmrjnptsxezjx4xuh2mn') { - handle = 'syui.ai'; + // Method 3: Fallback for admin DID + const adminDid = import.meta.env.VITE_ADMIN_DID; + if (did === adminDid) { + const appHost = import.meta.env.VITE_APP_HOST || 'https://syui.ai'; + handle = new URL(appHost).hostname; (this as any)._sessionInfo.handle = handle; - console.log('Using hardcoded handle for known DID'); + } } @@ -191,7 +187,7 @@ class AtprotoOAuthService { // Use environment variable if available const envClientId = import.meta.env.VITE_OAUTH_CLIENT_ID; if (envClientId) { - console.log('Using client ID from environment:', envClientId); + return envClientId; } @@ -200,7 +196,7 @@ class AtprotoOAuthService { // For localhost development, use undefined for loopback client // The BrowserOAuthClient will handle this automatically if (origin.includes('localhost') || origin.includes('127.0.0.1')) { - console.log('Using loopback client for localhost development'); + return undefined as any; // Loopback client } @@ -209,7 +205,7 @@ class AtprotoOAuthService { } private detectPDSFromHandle(handle: string): string { - console.log('Detecting PDS for handle:', handle); + // Supported PDS hosts and their corresponding handles const pdsMapping = { @@ -220,22 +216,22 @@ class AtprotoOAuthService { // Check if handle ends with known PDS domains for (const [domain, pdsUrl] of Object.entries(pdsMapping)) { if (handle.endsWith(`.${domain}`)) { - console.log(`Handle ${handle} mapped to PDS: ${pdsUrl}`); + return pdsUrl; } } // Default to bsky.social - console.log(`Handle ${handle} using default PDS: https://bsky.social`); + return 'https://bsky.social'; } async initiateOAuthFlow(handle?: string): Promise { try { - console.log('=== INITIATING OAUTH FLOW ==='); + if (!this.oauthClient) { - console.log('OAuth client not initialized, initializing now...'); + await this.initialize(); } @@ -251,15 +247,15 @@ class AtprotoOAuthService { } } - console.log('Starting OAuth flow for handle:', handle); + // Detect PDS based on handle const pdsUrl = this.detectPDSFromHandle(handle); - console.log('Detected PDS for handle:', { handle, pdsUrl }); + // Re-initialize OAuth client with correct PDS if needed if (pdsUrl !== 'https://bsky.social') { - console.log('Re-initializing OAuth client for custom PDS:', pdsUrl); + this.oauthClient = await BrowserOAuthClient.load({ clientId: this.getClientId(), handleResolver: pdsUrl, @@ -267,20 +263,14 @@ class AtprotoOAuthService { } // Start OAuth authorization flow - console.log('Calling oauthClient.authorize with handle:', handle); + try { const authUrl = await this.oauthClient.authorize(handle, { scope: 'atproto transition:generic', }); - console.log('Authorization URL generated:', authUrl.toString()); - console.log('URL breakdown:', { - protocol: authUrl.protocol, - hostname: authUrl.hostname, - pathname: authUrl.pathname, - search: authUrl.search - }); + // Store some debug info before redirect sessionStorage.setItem('oauth_debug_pre_redirect', JSON.stringify({ @@ -291,35 +281,30 @@ class AtprotoOAuthService { })); // Redirect to authorization server - console.log('About to redirect to:', authUrl.toString()); + window.location.href = authUrl.toString(); } catch (authorizeError) { - console.error('oauthClient.authorize failed:', authorizeError); - console.error('Error details:', { - name: authorizeError.name, - message: authorizeError.message, - stack: authorizeError.stack - }); + throw authorizeError; } } catch (error) { - console.error('Failed to initiate OAuth flow:', error); + throw new Error(`OAuth認証の開始に失敗しました: ${error}`); } } async handleOAuthCallback(): Promise<{ did: string; handle: string } | null> { try { - console.log('=== HANDLING OAUTH CALLBACK ==='); - console.log('Current URL:', window.location.href); - console.log('URL hash:', window.location.hash); - console.log('URL search:', window.location.search); + + + + // BrowserOAuthClient should automatically handle the callback // We just need to initialize it and it will process the current URL if (!this.oauthClient) { - console.log('OAuth client not initialized, initializing now...'); + await this.initialize(); } @@ -327,11 +312,11 @@ class AtprotoOAuthService { throw new Error('Failed to initialize OAuth client'); } - console.log('OAuth client ready, initializing to process callback...'); + // Call init() again to process the callback URL const result = await this.oauthClient.init(); - console.log('OAuth callback processing result:', result); + if (result?.session) { // Process the session @@ -339,47 +324,42 @@ class AtprotoOAuthService { } // If no session yet, wait a bit and try again - console.log('No session found immediately, waiting...'); + await new Promise(resolve => setTimeout(resolve, 1000)); // Try to check session again const sessionCheck = await this.checkSession(); if (sessionCheck) { - console.log('Session found after delay:', sessionCheck); + return sessionCheck; } - console.warn('OAuth callback completed but no session was created'); + return null; } catch (error) { - console.error('OAuth callback handling failed:', error); - console.error('Error details:', { - name: error.name, - message: error.message, - stack: error.stack - }); + throw new Error(`OAuth認証の完了に失敗しました: ${error.message}`); } } async checkSession(): Promise<{ did: string; handle: string } | null> { try { - console.log('=== CHECK SESSION CALLED ==='); + if (!this.oauthClient) { - console.log('No OAuth client, initializing...'); + await this.initialize(); } if (!this.oauthClient) { - console.log('OAuth client initialization failed'); + return null; } - console.log('Running oauthClient.init() to check session...'); + const result = await this.oauthClient.init(); - console.log('oauthClient.init() result:', result); + if (result?.session) { // Use the common session processing method @@ -388,7 +368,7 @@ class AtprotoOAuthService { return null; } catch (error) { - console.error('Session check failed:', error); + return null; } } @@ -398,13 +378,7 @@ class AtprotoOAuthService { } getSession(): AtprotoSession | null { - console.log('getSession called'); - console.log('Current state:', { - hasAgent: !!this.agent, - hasAgentSession: !!this.agent?.session, - hasOAuthClient: !!this.oauthClient, - hasSessionInfo: !!(this as any)._sessionInfo - }); + // First check if we have an agent with session if (this.agent?.session) { @@ -414,7 +388,7 @@ class AtprotoOAuthService { accessJwt: this.agent.session.accessJwt || '', refreshJwt: this.agent.session.refreshJwt || '', }; - console.log('Returning agent session:', session); + return session; } @@ -426,11 +400,11 @@ class AtprotoOAuthService { accessJwt: 'dpop-protected', // Indicate that tokens are handled by dpopFetch refreshJwt: 'dpop-protected', }; - console.log('Returning stored session info:', session); + return session; } - console.log('No session available'); + return null; } @@ -450,28 +424,28 @@ class AtprotoOAuthService { async logout(): Promise { try { - console.log('=== LOGGING OUT ==='); + // Clear Agent this.agent = null; - console.log('Agent cleared'); + // Clear BrowserOAuthClient session if (this.oauthClient) { - console.log('Clearing OAuth client session...'); + try { // BrowserOAuthClient may have a revoke or signOut method if (typeof (this.oauthClient as any).signOut === 'function') { await (this.oauthClient as any).signOut(); - console.log('OAuth client signed out'); + } else if (typeof (this.oauthClient as any).revoke === 'function') { await (this.oauthClient as any).revoke(); - console.log('OAuth client revoked'); + } else { - console.log('No explicit signOut method found on OAuth client'); + } } catch (oauthError) { - console.error('OAuth client logout error:', oauthError); + } // Reset the OAuth client to force re-initialization @@ -492,11 +466,11 @@ class AtprotoOAuthService { } } keysToRemove.forEach(key => { - console.log('Removing localStorage key:', key); + localStorage.removeItem(key); }); - console.log('=== LOGOUT COMPLETED ==='); + // Force page reload to ensure clean state setTimeout(() => { @@ -504,7 +478,7 @@ class AtprotoOAuthService { }, 100); } catch (error) { - console.error('Logout failed:', error); + } } @@ -519,8 +493,8 @@ class AtprotoOAuthService { const did = sessionInfo.did; try { - console.log('Saving cards to atproto collection...'); - console.log('Using DID:', did); + + // Ensure we have a fresh agent if (!this.agent) { @@ -550,13 +524,6 @@ class AtprotoOAuthService { createdAt: createdAt }; - console.log('PutRecord request:', { - repo: did, - collection: collection, - rkey: rkey, - record: record - }); - // Use Agent's com.atproto.repo.putRecord method const response = await this.agent.com.atproto.repo.putRecord({ @@ -566,9 +533,9 @@ class AtprotoOAuthService { record: record }); - console.log('カードデータをai.card.boxに保存しました:', response); + } catch (error) { - console.error('カードボックス保存エラー:', error); + throw error; } } @@ -584,8 +551,8 @@ class AtprotoOAuthService { const did = sessionInfo.did; try { - console.log('Fetching cards from atproto collection...'); - console.log('Using DID:', did); + + // Ensure we have a fresh agent if (!this.agent) { @@ -598,7 +565,7 @@ class AtprotoOAuthService { rkey: 'self' }); - console.log('Cards from box response:', response); + // Convert to expected format const result = { @@ -611,7 +578,7 @@ class AtprotoOAuthService { return result; } catch (error) { - console.error('カードボックス取得エラー:', error); + // If record doesn't exist, return empty if (error.toString().includes('RecordNotFound')) { @@ -633,8 +600,8 @@ class AtprotoOAuthService { const did = sessionInfo.did; try { - console.log('Deleting card box collection...'); - console.log('Using DID:', did); + + // Ensure we have a fresh agent if (!this.agent) { @@ -647,33 +614,35 @@ class AtprotoOAuthService { rkey: 'self' }); - console.log('Card box deleted successfully:', response); + } catch (error) { - console.error('カードボックス削除エラー:', error); + throw error; } } // 手動でトークンを設定(開発・デバッグ用) setManualTokens(accessJwt: string, refreshJwt: string): void { - console.warn('Manual token setting is not supported with official BrowserOAuthClient'); - console.warn('Please use the proper OAuth flow instead'); + + // For backward compatibility, store in localStorage + const adminDid = import.meta.env.VITE_ADMIN_DID || 'did:plc:unknown'; + const appHost = import.meta.env.VITE_APP_HOST || 'https://example.com'; const session: AtprotoSession = { - did: 'did:plc:uqzpqmrjnptsxezjx4xuh2mn', - handle: 'syui.ai', + did: adminDid, + handle: new URL(appHost).hostname, accessJwt: accessJwt, refreshJwt: refreshJwt }; localStorage.setItem('atproto_session', JSON.stringify(session)); - console.log('Manual tokens stored in localStorage for backward compatibility'); + } // 後方互換性のための従来関数 saveSessionToStorage(session: AtprotoSession): void { - console.warn('saveSessionToStorage is deprecated with BrowserOAuthClient'); + localStorage.setItem('atproto_session', JSON.stringify(session)); } diff --git a/oauth/src/utils/oauth-endpoints.ts b/oauth/src/utils/oauth-endpoints.ts index 9e7ab1a..62cada2 100644 --- a/oauth/src/utils/oauth-endpoints.ts +++ b/oauth/src/utils/oauth-endpoints.ts @@ -53,7 +53,6 @@ export class OAuthEndpointHandler { } }); } catch (error) { - console.error('Failed to generate JWKS:', error); return new Response(JSON.stringify({ error: 'Failed to generate JWKS' }), { status: 500, headers: { 'Content-Type': 'application/json' } @@ -62,7 +61,6 @@ export class OAuthEndpointHandler { } } catch (e) { // If URL parsing fails, pass through to original fetch - console.debug('URL parsing failed, passing through:', e); } // Pass through all other requests @@ -136,6 +134,5 @@ export function registerOAuthServiceWorker() { const blob = new Blob([swCode], { type: 'application/javascript' }); const swUrl = URL.createObjectURL(blob); - navigator.serviceWorker.register(swUrl).catch(console.error); } } \ No newline at end of file diff --git a/oauth/src/utils/oauth-keys.ts b/oauth/src/utils/oauth-keys.ts index 9be43d6..5ac150a 100644 --- a/oauth/src/utils/oauth-keys.ts +++ b/oauth/src/utils/oauth-keys.ts @@ -37,7 +37,6 @@ export class OAuthKeyManager { this.keyPair = await this.importKeyPair(keyData); return this.keyPair; } catch (error) { - console.warn('Failed to load stored key, generating new one:', error); localStorage.removeItem('oauth_private_key'); } } @@ -115,7 +114,6 @@ export class OAuthKeyManager { const privateKey = await window.crypto.subtle.exportKey('jwk', keyPair.privateKey); localStorage.setItem('oauth_private_key', JSON.stringify(privateKey)); } catch (error) { - console.error('Failed to store private key:', error); } } diff --git a/src/commands/oauth.rs b/src/commands/oauth.rs index 6f02ae0..f220697 100644 --- a/src/commands/oauth.rs +++ b/src/commands/oauth.rs @@ -49,49 +49,46 @@ pub async fn build(project_dir: PathBuf) -> Result<()> { .and_then(|v| v.as_str()) .unwrap_or("ai.syui.log"); - // Extract AI config if present - let ai_config = config.get("ai") - .and_then(|v| v.as_table()); - - let ai_enabled = ai_config - .and_then(|ai| ai.get("enabled")) - .and_then(|v| v.as_bool()) - .unwrap_or(false); - - let ai_ask_ai = ai_config - .and_then(|ai| ai.get("ask_ai")) - .and_then(|v| v.as_bool()) - .unwrap_or(false); - - let ai_provider = ai_config - .and_then(|ai| ai.get("provider")) - .and_then(|v| v.as_str()) - .unwrap_or("ollama"); - - let ai_model = ai_config - .and_then(|ai| ai.get("model")) - .and_then(|v| v.as_str()) - .unwrap_or("gemma2:2b"); - - let ai_host = ai_config - .and_then(|ai| ai.get("host")) - .and_then(|v| v.as_str()) - .unwrap_or("https://ollama.syui.ai"); - - let ai_system_prompt = ai_config - .and_then(|ai| ai.get("system_prompt")) - .and_then(|v| v.as_str()) - .unwrap_or("you are a helpful ai assistant"); - + // Extract AI configuration from ai config if available + let ai_config = config.get("ai").and_then(|v| v.as_table()); let ai_did = ai_config - .and_then(|ai| ai.get("ai_did")) + .and_then(|ai_table| ai_table.get("ai_did")) .and_then(|v| v.as_str()) .unwrap_or("did:plc:4hqjfn7m6n5hno3doamuhgef"); + let ai_enabled = ai_config + .and_then(|ai_table| ai_table.get("enabled")) + .and_then(|v| v.as_bool()) + .unwrap_or(true); + let ai_ask_ai = ai_config + .and_then(|ai_table| ai_table.get("ask_ai")) + .and_then(|v| v.as_bool()) + .unwrap_or(true); + let ai_provider = ai_config + .and_then(|ai_table| ai_table.get("provider")) + .and_then(|v| v.as_str()) + .unwrap_or("ollama"); + let ai_model = ai_config + .and_then(|ai_table| ai_table.get("model")) + .and_then(|v| v.as_str()) + .unwrap_or("gemma3:4b"); + let ai_host = ai_config + .and_then(|ai_table| ai_table.get("host")) + .and_then(|v| v.as_str()) + .unwrap_or("https://ollama.syui.ai"); + let ai_system_prompt = ai_config + .and_then(|ai_table| ai_table.get("system_prompt")) + .and_then(|v| v.as_str()) + .unwrap_or("あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"); // Extract bsky_api from oauth config let bsky_api = oauth_config.get("bsky_api") .and_then(|v| v.as_str()) .unwrap_or("https://public.api.bsky.app"); + + // Extract atproto_api from oauth config + let atproto_api = oauth_config.get("atproto_api") + .and_then(|v| v.as_str()) + .unwrap_or("https://bsky.social"); // 4. Create .env.production content let env_content = format!( @@ -101,7 +98,7 @@ VITE_OAUTH_CLIENT_ID={}/{} VITE_OAUTH_REDIRECT_URI={}/{} VITE_ADMIN_DID={} -# Base collection for OAuth app and ailog (all others are derived) +# Base collection (all others are derived via getCollectionNames) VITE_OAUTH_COLLECTION={} # AI Configuration @@ -115,6 +112,7 @@ VITE_AI_DID={} # API Configuration VITE_BSKY_PUBLIC_API={} +VITE_ATPROTO_API={} "#, base_url, base_url, client_id_path, @@ -128,7 +126,8 @@ VITE_BSKY_PUBLIC_API={} ai_host, ai_system_prompt, ai_did, - bsky_api + bsky_api, + atproto_api ); // 5. Find oauth directory (relative to current working directory) diff --git a/src/commands/stream.rs b/src/commands/stream.rs index 2f2ecbd..6f1a7aa 100644 --- a/src/commands/stream.rs +++ b/src/commands/stream.rs @@ -14,6 +14,29 @@ use reqwest; use super::auth::{load_config, load_config_with_refresh, AuthConfig}; +#[derive(Debug, Clone)] +struct AiConfig { + blog_host: String, + ollama_host: String, + ai_did: String, + model: String, + system_prompt: String, + bsky_api: String, +} + +impl Default for AiConfig { + fn default() -> Self { + Self { + blog_host: "https://syui.ai".to_string(), + ollama_host: "https://ollama.syui.ai".to_string(), + ai_did: "did:plc:4hqjfn7m6n5hno3doamuhgef".to_string(), + model: "gemma3:4b".to_string(), + system_prompt: "あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。相手のことが大好きで、ときどき甘えたり、照れたりします。でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。".to_string(), + bsky_api: "https://public.api.bsky.app".to_string(), + } + } +} + #[derive(Debug, Deserialize)] #[allow(dead_code)] struct BlogPost { @@ -112,6 +135,83 @@ 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 { + // Try to find config.toml in current directory or parent directories + let mut current_dir = std::env::current_dir()?; + let mut config_path = None; + + for _ in 0..5 { // Search up to 5 levels up + let potential_config = current_dir.join("config.toml"); + if potential_config.exists() { + config_path = Some(potential_config); + break; + } + if !current_dir.pop() { + break; + } + } + + let config_path = config_path.ok_or_else(|| anyhow::anyhow!("config.toml not found in current directory or parent directories"))?; + + let config_content = fs::read_to_string(&config_path) + .with_context(|| format!("Failed to read config.toml from {}", config_path.display()))?; + + let config: toml::Value = config_content.parse() + .with_context(|| "Failed to parse config.toml")?; + + // Extract site config + let site_config = config.get("site").and_then(|v| v.as_table()); + let blog_host = site_config + .and_then(|s| s.get("base_url")) + .and_then(|v| v.as_str()) + .unwrap_or("https://syui.ai") + .to_string(); + + // Extract AI config + let ai_config = config.get("ai").and_then(|v| v.as_table()); + let ollama_host = ai_config + .and_then(|ai| ai.get("host")) + .and_then(|v| v.as_str()) + .unwrap_or("https://ollama.syui.ai") + .to_string(); + + let ai_did = ai_config + .and_then(|ai| ai.get("ai_did")) + .and_then(|v| v.as_str()) + .unwrap_or("did:plc:4hqjfn7m6n5hno3doamuhgef") + .to_string(); + + let model = ai_config + .and_then(|ai| ai.get("model")) + .and_then(|v| v.as_str()) + .unwrap_or("gemma3:4b") + .to_string(); + + let system_prompt = ai_config + .and_then(|ai| ai.get("system_prompt")) + .and_then(|v| v.as_str()) + .unwrap_or("あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。相手のことが大好きで、ときどき甘えたり、照れたりします。でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。") + .to_string(); + + // Extract OAuth config for bsky_api + let oauth_config = config.get("oauth").and_then(|v| v.as_table()); + let bsky_api = oauth_config + .and_then(|oauth| oauth.get("bsky_api")) + .and_then(|v| v.as_str()) + .unwrap_or("https://public.api.bsky.app") + .to_string(); + + Ok(AiConfig { + blog_host, + ollama_host, + ai_did, + model, + system_prompt, + bsky_api, + }) +} + #[derive(Debug, Serialize, Deserialize)] struct JetstreamMessage { collection: Option, @@ -432,6 +532,7 @@ async fn handle_message(text: &str, config: &mut AuthConfig) -> Result<()> { async fn resolve_handle(did: &str) -> Result { let client = reqwest::Client::new(); + // Use default bsky API for handle resolution let url = format!("https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor={}", urlencoding::encode(did)); @@ -931,27 +1032,51 @@ pub async fn test_api() -> Result<()> { } // AI content generation functions -async fn generate_ai_content(content: &str, prompt_type: &str, ollama_host: &str) -> Result { - let model = "gemma3:4b"; +async fn generate_ai_content(content: &str, prompt_type: &str, ai_config: &AiConfig) -> Result { + let model = &ai_config.model; + let system_prompt = &ai_config.system_prompt; let prompt = match prompt_type { - "translate" => format!("Translate the following Japanese blog post to English. Keep the technical terms and code blocks intact:\n\n{}", content), - "comment" => format!("Read this blog post and provide an insightful comment about it. Focus on the key points and add your perspective:\n\n{}", content), + "translate" => format!( + "{}\n\n# 指示\n以下の日本語ブログ記事を英語に翻訳してください。\n- 技術用語やコードブロックはそのまま維持\n- アイらしい表現で翻訳\n- 簡潔に要点をまとめる\n\n# ブログ記事\n{}", + system_prompt, content + ), + "comment" => { + // Limit content to first 500 characters to reduce input size + let limited_content = if content.len() > 500 { + format!("{}...", &content[..500]) + } else { + content.to_string() + }; + + format!( + "{}\n\n# 指示\nこのブログ記事を読んで、アイらしい感想を一言でください。\n- 30文字以内の短い感想\n- 技術的な内容への素朴な驚きや発見\n- 「わー!」「すごい!」など、アイらしい感嘆詞で始める\n- 簡潔で分かりやすく\n\n# ブログ記事(要約)\n{}\n\n# 出力形式\n一言の感想のみ(説明や詳細は不要):", + system_prompt, limited_content + ) + }, _ => return Err(anyhow::anyhow!("Unknown prompt type: {}", prompt_type)), }; + let num_predict = match prompt_type { + "comment" => 50, // Very short for comments (about 30-40 characters) + "translate" => 3000, // Much longer for translations + _ => 300, + }; + let request = OllamaRequest { model: model.to_string(), prompt, stream: false, options: OllamaOptions { - temperature: 0.9, - top_p: 0.9, - num_predict: 500, + temperature: 0.7, // Lower temperature for more focused responses + top_p: 0.8, + num_predict, }, }; - let client = reqwest::Client::new(); + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(120)) // 2 minute timeout + .build()?; // Try localhost first (for same-server deployment) let localhost_url = "http://localhost:11434/api/generate"; @@ -967,8 +1092,14 @@ async fn generate_ai_content(content: &str, prompt_type: &str, ollama_host: &str } // Fallback to remote host - let remote_url = format!("{}/api/generate", ollama_host); - let response = client.post(&remote_url).json(&request).send().await?; + 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?; if !response.status().is_success() { return Err(anyhow::anyhow!("Ollama API request failed: {}", response.status())); @@ -980,9 +1111,15 @@ async fn generate_ai_content(content: &str, prompt_type: &str, ollama_host: &str } async fn run_ai_generation_monitor(config: &AuthConfig) -> Result<()> { - let blog_host = "https://syui.ai"; // TODO: Load from config - let ollama_host = "https://ollama.syui.ai"; // TODO: Load from config - let ai_did = "did:plc:4hqjfn7m6n5hno3doamuhgef"; // TODO: Load from config + // Load AI config from project config.toml or use defaults + let ai_config = load_ai_config_from_project().unwrap_or_else(|e| { + println!("{}", format!("⚠️ Failed to load AI config: {}, using defaults", e).yellow()); + AiConfig::default() + }); + + let blog_host = &ai_config.blog_host; + let ollama_host = &ai_config.ollama_host; + let ai_did = &ai_config.ai_did; println!("{}", "🤖 Starting AI content generation monitor...".cyan()); println!("📡 Blog host: {}", blog_host); @@ -998,7 +1135,7 @@ async fn run_ai_generation_monitor(config: &AuthConfig) -> Result<()> { println!("{}", "🔍 Checking for new blog posts...".blue()); - match check_and_process_new_posts(&client, config, blog_host, ollama_host, ai_did).await { + match check_and_process_new_posts(&client, config, &ai_config).await { Ok(count) => { if count > 0 { println!("{}", format!("✅ Processed {} new posts", count).green()); @@ -1018,12 +1155,10 @@ async fn run_ai_generation_monitor(config: &AuthConfig) -> Result<()> { async fn check_and_process_new_posts( client: &reqwest::Client, config: &AuthConfig, - blog_host: &str, - ollama_host: &str, - ai_did: &str, + ai_config: &AiConfig, ) -> Result { // Fetch blog index - let index_url = format!("{}/index.json", blog_host); + let index_url = format!("{}/index.json", ai_config.blog_host); let response = client.get(&index_url).send().await?; if !response.status().is_success() { @@ -1042,25 +1177,57 @@ async fn check_and_process_new_posts( for post in blog_posts { let post_slug = extract_slug_from_url(&post.href); - // Check if translation already exists + // Check if translation already exists (support both old and new format) let translation_exists = existing_lang_records.iter().any(|record| { - record.get("value") + let value = record.get("value"); + + // Check new format: value.post.slug + let new_format_match = value + .and_then(|v| v.get("post")) + .and_then(|p| p.get("slug")) + .and_then(|s| s.as_str()) + == Some(&post_slug); + + // Check old format: value.post_slug + let old_format_match = value .and_then(|v| v.get("post_slug")) .and_then(|s| s.as_str()) - == Some(&post_slug) + == Some(&post_slug); + + new_format_match || old_format_match }); - // Check if comment already exists + if translation_exists { + println!("{}", format!("⏭️ Translation already exists for: {}", post.title).yellow()); + } + + // Check if comment already exists (support both old and new format) let comment_exists = existing_comment_records.iter().any(|record| { - record.get("value") + let value = record.get("value"); + + // Check new format: value.post.slug + let new_format_match = value + .and_then(|v| v.get("post")) + .and_then(|p| p.get("slug")) + .and_then(|s| s.as_str()) + == Some(&post_slug); + + // Check old format: value.post_slug + let old_format_match = value .and_then(|v| v.get("post_slug")) .and_then(|s| s.as_str()) - == Some(&post_slug) + == Some(&post_slug); + + new_format_match || old_format_match }); + if comment_exists { + println!("{}", format!("⏭️ Comment already exists for: {}", post.title).yellow()); + } + // Generate translation if not exists if !translation_exists { - match generate_and_store_translation(client, config, &post, ollama_host, ai_did).await { + match generate_and_store_translation(client, config, &post, ai_config).await { Ok(_) => { println!("{}", format!("✅ Generated translation for: {}", post.title).green()); processed_count += 1; @@ -1069,11 +1236,13 @@ async fn check_and_process_new_posts( println!("{}", format!("❌ Failed to generate translation for {}: {}", post.title, e).red()); } } + } else { + println!("{}", format!("⏭️ Translation already exists for: {}", post.title).yellow()); } // Generate comment if not exists if !comment_exists { - match generate_and_store_comment(client, config, &post, ollama_host, ai_did).await { + match generate_and_store_comment(client, config, &post, ai_config).await { Ok(_) => { println!("{}", format!("✅ Generated comment for: {}", post.title).green()); processed_count += 1; @@ -1082,6 +1251,8 @@ async fn check_and_process_new_posts( println!("{}", format!("❌ Failed to generate comment for {}: {}", post.title, e).red()); } } + } else { + println!("{}", format!("⏭️ Comment already exists for: {}", post.title).yellow()); } } @@ -1120,25 +1291,76 @@ fn extract_slug_from_url(url: &str) -> String { .to_string() } +fn extract_date_from_slug(slug: &str) -> String { + // Extract date from slug like "2025-06-14-blog" -> "2025-06-14T00:00:00Z" + if slug.len() >= 10 && slug.chars().nth(4) == Some('-') && slug.chars().nth(7) == Some('-') { + format!("{}T00:00:00Z", &slug[0..10]) + } else { + chrono::Utc::now().format("%Y-%m-%dT00:00:00Z").to_string() + } +} + +async fn get_ai_profile(client: &reqwest::Client, ai_config: &AiConfig) -> Result { + let url = format!("{}/xrpc/app.bsky.actor.getProfile?actor={}", + ai_config.bsky_api, urlencoding::encode(&ai_config.ai_did)); + + let response = client + .get(&url) + .send() + .await?; + + if !response.status().is_success() { + // Fallback to default AI profile + return Ok(serde_json::json!({ + "did": ai_config.ai_did, + "handle": "yui.syui.ai", + "displayName": "ai", + "avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:4hqjfn7m6n5hno3doamuhgef/bafkreiaxkv624mffw3cfyi67ufxtwuwsy2mjw2ygezsvtd44ycbgkfdo2a@jpeg" + })); + } + + 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"), + "displayName": profile_data["displayName"].as_str().unwrap_or("ai"), + "avatar": profile_data["avatar"].as_str() + })) +} + async fn generate_and_store_translation( client: &reqwest::Client, config: &AuthConfig, post: &BlogPost, - ollama_host: &str, - ai_did: &str, + ai_config: &AiConfig, ) -> Result<()> { - // Generate translation - let translation = generate_ai_content(&post.title, "translate", ollama_host).await?; + // Generate translation using post content instead of just title + let content_to_translate = format!("Title: {}\n\n{}", post.title, post.contents); + let translation = generate_ai_content(&content_to_translate, "translate", ai_config).await?; - // Store in ai.syui.log.chat.lang collection + // Get AI profile information + let ai_author = get_ai_profile(client, ai_config).await?; + + // Extract post metadata + let post_slug = extract_slug_from_url(&post.href); + let post_date = extract_date_from_slug(&post_slug); + + // Store in ai.syui.log.chat.lang collection with new format let record_data = serde_json::json!({ - "post_slug": extract_slug_from_url(&post.href), - "post_title": post.title, - "post_url": post.href, - "lang": "en", - "content": translation, - "generated_at": chrono::Utc::now().to_rfc3339(), - "ai_did": ai_did + "$type": "ai.syui.log.chat.lang", + "post": { + "url": post.href, + "slug": post_slug, + "title": post.title, + "date": post_date, + "tags": post.tags, + "language": "ja" + }, + "type": "en", + "text": translation, + "author": ai_author, + "createdAt": chrono::Utc::now().to_rfc3339() }); store_atproto_record(client, config, &config.collections.chat_lang(), &record_data).await @@ -1148,20 +1370,39 @@ async fn generate_and_store_comment( client: &reqwest::Client, config: &AuthConfig, post: &BlogPost, - ollama_host: &str, - ai_did: &str, + ai_config: &AiConfig, ) -> Result<()> { - // Generate comment - let comment = generate_ai_content(&post.title, "comment", ollama_host).await?; + // Generate comment using limited post content for brevity + let limited_contents = if post.contents.len() > 300 { + format!("{}...", &post.contents[..300]) + } else { + post.contents.clone() + }; + let content_to_comment = format!("Title: {}\n\n{}", post.title, limited_contents); + let comment = generate_ai_content(&content_to_comment, "comment", ai_config).await?; - // Store in ai.syui.log.chat.comment collection + // Get AI profile information + let ai_author = get_ai_profile(client, ai_config).await?; + + // Extract post metadata + let post_slug = extract_slug_from_url(&post.href); + let post_date = extract_date_from_slug(&post_slug); + + // Store in ai.syui.log.chat.comment collection with new format let record_data = serde_json::json!({ - "post_slug": extract_slug_from_url(&post.href), - "post_title": post.title, - "post_url": post.href, - "content": comment, - "generated_at": chrono::Utc::now().to_rfc3339(), - "ai_did": ai_did + "$type": "ai.syui.log.chat.comment", + "post": { + "url": post.href, + "slug": post_slug, + "title": post.title, + "date": post_date, + "tags": post.tags, + "language": "ja" + }, + "type": "info", + "text": comment, + "author": ai_author, + "createdAt": chrono::Utc::now().to_rfc3339() }); store_atproto_record(client, config, &config.collections.chat_comment(), &record_data).await @@ -1169,10 +1410,13 @@ async fn generate_and_store_comment( async fn store_atproto_record( client: &reqwest::Client, - config: &AuthConfig, + _config: &AuthConfig, collection: &str, record_data: &serde_json::Value, ) -> Result<()> { + // Always load fresh config to ensure we have valid tokens + let config = load_config_with_refresh().await?; + let url = format!("{}/xrpc/com.atproto.repo.putRecord", config.admin.pds); let put_request = serde_json::json!({