test update json

This commit is contained in:
2025-06-15 18:42:49 +09:00
parent 4df7f72312
commit 7e1ed39d06
13 changed files with 540 additions and 473 deletions

View File

@@ -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": []
}

1
.gitignore vendored
View File

@@ -12,3 +12,4 @@ node_modules
package-lock.json
my-blog/static/assets/comment-atproto-*
bin/ailog
docs

View File

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

View File

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

View File

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

View File

@@ -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<User | null>(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,27 +203,46 @@ 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 admin's user list records
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,
});
console.log('AI chat history loaded:', response.data);
const chatRecords = response.data.records || [];
const userListData = await userListResponse.json();
const userRecords = userListData.records || [];
// Extract unique DIDs from user records
const userDids = [...new Set(userRecords.map(record => record.value.did).filter(Boolean))];
// Load chat records from all registered users
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) {
// Skip failed users
continue;
}
}
// Filter out old records with invalid AI profile data (temporary fix for migration)
const validRecords = chatRecords.filter(record => {
const validRecords = allChatRecords.filter(record => {
if (record.value.answer) {
// This is an AI answer - check if it has valid AI profile
return record.value.author?.handle &&
@@ -266,16 +252,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 +267,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 +345,7 @@ function App() {
}
};
} catch (err) {
console.warn('Failed to enhance comment with profile:', err);
// Ignore enhancement errors
return record;
}
}
@@ -366,7 +355,7 @@ function App() {
setComments(enhancedComments);
} catch (err) {
console.error('Failed to load comments:', err);
// Ignore load errors
setComments([]);
}
};
@@ -376,20 +365,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 +390,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 +404,7 @@ function App() {
}
}
} catch (err) {
console.warn(`Failed to resolve DID for ${user.handle}:`, err);
// Failed to resolve DID
}
}
return user;
@@ -425,10 +414,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 +425,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 +443,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 +466,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 +512,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 +561,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 +583,6 @@ function App() {
localStorage.setItem(cacheKey, JSON.stringify(cacheData));
}
} catch (err) {
console.error('Failed to load all comments:', err);
setComments([]);
}
};
@@ -640,7 +624,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 +643,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 +650,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 +659,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 +695,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 +765,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 +782,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 +794,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 +832,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 +866,6 @@ function App() {
try {
await atprotoOAuthService.initiateOAuthFlow(handleInput);
} catch (err) {
console.error('OAuth failed:', err);
alert('認証の開始に失敗しました。再度お試しください。');
}
};
@@ -918,7 +888,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 +902,130 @@ 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 (
<div key={index} className={className}>
<div className="content-header">
<img
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 || 'ai'}
</span>
<span className="content-type-badge">{getTypeLabel(value.$type, contentType)}</span>
</div>
<span className="comment-date">
{new Date(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>
</div>
</div>
{/* Post information for new format */}
{postInfo && (
<div className="post-info">
<h4>{postInfo.title}</h4>
{postInfo.tags && (
<div className="tags">
{postInfo.tags.map((tag: string) => (
<span key={tag} className="tag">#{tag}</span>
))}
</div>
)}
</div>
)}
{/* Legacy post information for old format */}
{!postInfo && (value.post_title || value.post_url) && (
<div className="post-info">
<h4>{value.post_title || 'Unknown'}</h4>
</div>
)}
<div className="content-text">
{contentText}
</div>
<div className="comment-meta">
{(postInfo?.url || value.post_url) && (
<small>
<a href={postInfo?.url || value.post_url}>
{postInfo?.url || 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>
);
};
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 (
<div className="app">
@@ -1081,14 +1175,12 @@ function App() {
>
Comments ({comments.filter(shouldShowComment).length})
</button>
{user && (
<button
className={`tab-button ${activeTab === 'ai-chat' ? 'active' : ''}`}
onClick={() => setActiveTab('ai-chat')}
>
AI Chat History ({aiChatHistory.length})
</button>
)}
<button
className={`tab-button ${activeTab === 'ai-chat' ? 'active' : ''}`}
onClick={() => setActiveTab('ai-chat')}
>
AI Chat History ({aiChatHistory.length})
</button>
<button
className={`tab-button ${activeTab === 'lang-en' ? 'active' : ''}`}
onClick={() => setActiveTab('lang-en')}
@@ -1129,7 +1221,6 @@ function App() {
}
})
.catch(err => {
console.warn('Failed to fetch fresh avatar:', err);
// Keep placeholder on error
});
}
@@ -1196,7 +1287,7 @@ function App() {
)}
{/* AI Chat History List */}
{activeTab === 'ai-chat' && user && (
{activeTab === 'ai-chat' && (
<div className="ai-chat-list">
<div className="chat-header">
<h3>AI Chat History</h3>
@@ -1222,7 +1313,6 @@ function App() {
}
})
.catch(err => {
console.warn('Failed to fetch fresh avatar:', err);
// Keep placeholder on error
});
}
@@ -1287,37 +1377,9 @@ function App() {
{langEnRecords.length === 0 ? (
<p className="no-content">No English translations yet</p>
) : (
langEnRecords.map((record, index) => (
<div key={index} className="lang-item">
<div className="lang-header">
<img
src={record.value.author?.avatar || generatePlaceholderAvatar(record.value.author?.handle || 'AI')}
alt="AI Avatar"
className="comment-avatar"
/>
<div className="comment-author-info">
<span className="comment-author">
{record.value.author?.displayName || 'AI Translator'}
</span>
<span className="comment-handle">
@{record.value.author?.handle || 'ai'}
</span>
</div>
<span className="comment-date">
{new Date(record.value.createdAt).toLocaleString()}
</span>
</div>
<div className="lang-content">
<div className="lang-type">Type: {record.value.type || 'en'}</div>
<div className="lang-body">{record.value.body}</div>
</div>
<div className="comment-meta">
{record.value.url && (
<small><a href={record.value.url}>{record.value.url}</a></small>
)}
</div>
</div>
))
langEnRecords.map((record, index) =>
renderAIContent(record, index, 'lang-item')
)
)}
</div>
)}
@@ -1328,37 +1390,9 @@ function App() {
{aiCommentRecords.length === 0 ? (
<p className="no-content">No AI comments yet</p>
) : (
aiCommentRecords.map((record, index) => (
<div key={index} className="ai-comment-item">
<div className="ai-comment-header">
<img
src={record.value.author?.avatar || generatePlaceholderAvatar(record.value.author?.handle || 'AI')}
alt="AI Avatar"
className="comment-avatar"
/>
<div className="comment-author-info">
<span className="comment-author">
{record.value.author?.displayName || 'AI Commenter'}
</span>
<span className="comment-handle">
@{record.value.author?.handle || 'ai'}
</span>
</div>
<span className="comment-date">
{new Date(record.value.createdAt).toLocaleString()}
</span>
</div>
<div className="ai-comment-content">
<div className="ai-comment-type">Type: {record.value.type || 'comment'}</div>
<div className="ai-comment-body">{record.value.body}</div>
</div>
<div className="comment-meta">
{record.value.url && (
<small><a href={record.value.url}>{record.value.url}</a></small>
)}
</div>
</div>
))
aiCommentRecords.map((record, index) =>
renderAIContent(record, index, 'ai-comment-item')
)
)}
</div>
)}

View File

@@ -12,17 +12,25 @@ export interface AppConfig {
aiModel: string;
aiHost: 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 +51,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';
}
@@ -66,8 +74,15 @@ export function getAppConfig(): AppConfig {
// 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();
@@ -79,15 +94,8 @@ export function getAppConfig(): AppConfig {
const aiModel = import.meta.env.VITE_AI_MODEL || 'gemma2:2b';
const aiHost = import.meta.env.VITE_AI_HOST || 'https://ollama.syui.ai';
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,
@@ -99,7 +107,8 @@ export function getAppConfig(): AppConfig {
aiProvider,
aiModel,
aiHost,
bskyPublicApi
bskyPublicApi,
atprotoApi
};
}

View File

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

View File

@@ -31,11 +31,11 @@ class AtprotoOAuthService {
private async _doInitialize(): Promise<void> {
try {
console.log('=== INITIALIZING ATPROTO OAUTH CLIENT ===');
// Generate client ID based on current origin
const clientId = this.getClientId();
console.log('Client ID:', clientId);
// Support multiple PDS hosts for OAuth
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<void> {
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<void> {
try {
console.log('=== LOGGING OUT ===');
// Clear Agent
this.agent = null;
console.log('Agent cleared');
// Clear BrowserOAuthClient session
if (this.oauthClient) {
console.log('Clearing OAuth client session...');
try {
// BrowserOAuthClient may have a revoke or signOut method
if (typeof (this.oauthClient as any).signOut === 'function') {
await (this.oauthClient as any).signOut();
console.log('OAuth client signed out');
} else if (typeof (this.oauthClient as any).revoke === 'function') {
await (this.oauthClient as any).revoke();
console.log('OAuth client revoked');
} else {
console.log('No explicit signOut method found on OAuth client');
}
} catch (oauthError) {
console.error('OAuth client logout error:', oauthError);
}
// Reset the OAuth client to force re-initialization
@@ -492,11 +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));
}

View File

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

View File

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

View File

@@ -49,49 +49,16 @@ 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");
let ai_did = ai_config
.and_then(|ai| ai.get("ai_did"))
.and_then(|v| v.as_str())
.unwrap_or("did:plc:4hqjfn7m6n5hno3doamuhgef");
// 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,34 +68,20 @@ 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
VITE_AI_ENABLED={}
VITE_AI_ASK_AI={}
VITE_AI_PROVIDER={}
VITE_AI_MODEL={}
VITE_AI_HOST={}
VITE_AI_SYSTEM_PROMPT="{}"
VITE_AI_DID={}
# API Configuration
VITE_BSKY_PUBLIC_API={}
VITE_ATPROTO_API={}
"#,
base_url,
base_url, client_id_path,
base_url, redirect_path,
admin_did,
collection_base,
ai_enabled,
ai_ask_ai,
ai_provider,
ai_model,
ai_host,
ai_system_prompt,
ai_did,
bsky_api
bsky_api,
atproto_api
);
// 5. Find oauth directory (relative to current working directory)

View File

@@ -931,12 +931,20 @@ pub async fn test_api() -> Result<()> {
}
// AI content generation functions
async fn generate_ai_content(content: &str, prompt_type: &str, ollama_host: &str) -> Result<String> {
async fn generate_ai_content(content: &str, prompt_type: &str, ollama_host: &str, blog_host: &str) -> Result<String> {
let model = "gemma3:4b";
let system_prompt = "あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。相手のことが大好きで、ときどき甘えたり、照れたりします。でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。";
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" => format!(
"{}\n\n# 指示\nこのブログ記事を読んで、アイらしいコメントをしてください。\n- 技術的な内容への感想\n- アイの視点からの面白い発見\n- 短めに、でも内容のあるコメント\n\n# ブログ記事\n{}",
system_prompt, content
),
_ => return Err(anyhow::anyhow!("Unknown prompt type: {}", prompt_type)),
};
@@ -947,11 +955,13 @@ async fn generate_ai_content(content: &str, prompt_type: &str, ollama_host: &str
options: OllamaOptions {
temperature: 0.9,
top_p: 0.9,
num_predict: 500,
num_predict: 300, // Shorter responses for comments
},
};
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";
@@ -968,7 +978,13 @@ 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?;
println!("{}", format!("🔗 Making request to: {} with Origin: {}", remote_url, blog_host).blue());
let response = client
.post(&remote_url)
.header("Origin", blog_host)
.json(&request)
.send()
.await?;
if !response.status().is_success() {
return Err(anyhow::anyhow!("Ollama API request failed: {}", response.status()));
@@ -1042,25 +1058,49 @@ 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
// 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
});
// 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, ollama_host, blog_host, ai_did).await {
Ok(_) => {
println!("{}", format!("✅ Generated translation for: {}", post.title).green());
processed_count += 1;
@@ -1069,11 +1109,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, ollama_host, blog_host, ai_did).await {
Ok(_) => {
println!("{}", format!("✅ Generated comment for: {}", post.title).green());
processed_count += 1;
@@ -1082,6 +1124,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 +1164,78 @@ 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_did: &str) -> Result<serde_json::Value> {
let url = format!("https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor={}",
urlencoding::encode(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_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_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,
blog_host: &str,
ai_did: &str,
) -> 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", ollama_host, blog_host).await?;
// Store in ai.syui.log.chat.lang collection
// Get AI profile information
let ai_author = get_ai_profile(client, ai_did).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
@@ -1149,19 +1246,35 @@ async fn generate_and_store_comment(
config: &AuthConfig,
post: &BlogPost,
ollama_host: &str,
blog_host: &str,
ai_did: &str,
) -> Result<()> {
// Generate comment
let comment = generate_ai_content(&post.title, "comment", ollama_host).await?;
// Generate comment using post content instead of just title
let content_to_comment = format!("Title: {}\n\n{}", post.title, post.contents);
let comment = generate_ai_content(&content_to_comment, "comment", ollama_host, blog_host).await?;
// Store in ai.syui.log.chat.comment collection
// Get AI profile information
let ai_author = get_ai_profile(client, ai_did).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 +1282,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!({