import React, { useState, useEffect } from 'react'
import { atproto } from './api/atproto.js'
import { useAuth } from './hooks/useAuth.js'
import { useAdminData } from './hooks/useAdminData.js'
import { useUserData } from './hooks/useUserData.js'
import { usePageContext } from './hooks/usePageContext.js'
import AuthButton from './components/AuthButton.jsx'
import RecordTabs from './components/RecordTabs.jsx'
import CommentForm from './components/CommentForm.jsx'
import ProfileForm from './components/ProfileForm.jsx'
import AskAI from './components/AskAI.jsx'
import TestUI from './components/TestUI.jsx'
import OAuthCallback from './components/OAuthCallback.jsx'
export default function App() {
const { user, agent, loading: authLoading, login, logout } = useAuth()
const { adminData, langRecords, commentRecords, loading: dataLoading, error, retryCount, refresh: refreshAdminData } = useAdminData()
const { userComments, chatRecords, loading: userLoading, refresh: refreshUserData } = useUserData(adminData)
const [userChatRecords, setUserChatRecords] = useState([])
const [userChatLoading, setUserChatLoading] = useState(false)
const pageContext = usePageContext()
const [showAskAI, setShowAskAI] = useState(false)
const [showTestUI, setShowTestUI] = useState(false)
// Environment-based feature flags
const ENABLE_TEST_UI = import.meta.env.VITE_ENABLE_TEST_UI === 'true'
const ENABLE_DEBUG = import.meta.env.VITE_ENABLE_DEBUG === 'true'
// Fetch user's own chat records
const fetchUserChatRecords = async () => {
if (!user || !agent) return
setUserChatLoading(true)
try {
const records = await agent.api.com.atproto.repo.listRecords({
repo: user.did,
collection: 'ai.syui.log.chat',
limit: 50
})
// Group questions and answers together
const chatPairs = []
const recordMap = new Map()
// First pass: organize records by base rkey
records.data.records.forEach(record => {
const rkey = record.uri.split('/').pop()
const baseRkey = rkey.replace('-answer', '')
if (!recordMap.has(baseRkey)) {
recordMap.set(baseRkey, { question: null, answer: null })
}
if (record.value.type === 'question') {
recordMap.get(baseRkey).question = record
} else if (record.value.type === 'answer') {
recordMap.get(baseRkey).answer = record
}
})
// Second pass: create chat pairs
recordMap.forEach((pair, rkey) => {
if (pair.question) {
chatPairs.push({
rkey,
question: pair.question,
answer: pair.answer,
createdAt: pair.question.value.createdAt
})
}
})
// Sort by creation time (newest first)
chatPairs.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
setUserChatRecords(chatPairs)
} catch (error) {
console.error('Failed to fetch user chat records:', error)
setUserChatRecords([])
} finally {
setUserChatLoading(false)
}
}
// Fetch user chat records when user/agent changes
useEffect(() => {
fetchUserChatRecords()
}, [user, agent])
// Expose AI profile data to blog's ask-ai.js
useEffect(() => {
if (adminData?.profile) {
console.log('AI profile loaded:', adminData.profile)
// Make AI profile data available globally for ask-ai.js
window.aiProfileData = {
did: adminData.did,
handle: adminData.profile.handle,
displayName: adminData.profile.displayName,
avatar: adminData.profile.avatar
}
// Dispatch event to notify ask-ai.js
window.dispatchEvent(new CustomEvent('aiProfileLoaded', {
detail: window.aiProfileData
}))
}
}, [adminData])
// Event listeners for blog communication
useEffect(() => {
// Clear OAuth completion flag once app is loaded
if (sessionStorage.getItem('oauth_just_completed') === 'true') {
setTimeout(() => {
sessionStorage.removeItem('oauth_just_completed')
}, 1000)
}
const handleAIQuestion = async (event) => {
const { question } = event.detail
if (question && adminData && user && agent) {
try {
console.log('Processing AI question:', question)
// AI設定
const aiConfig = {
host: import.meta.env.VITE_AI_HOST || 'https://ollama.syui.ai',
model: import.meta.env.VITE_AI_MODEL || 'gemma3:1b',
systemPrompt: import.meta.env.VITE_AI_SYSTEM_PROMPT || 'あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。'
}
const prompt = `${aiConfig.systemPrompt}
Question: ${question}
Answer:`
// Ollamaに直接リクエスト
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 30000)
const response = await fetch(`${aiConfig.host}/api/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Origin': 'https://syui.ai',
},
body: JSON.stringify({
model: aiConfig.model,
prompt: prompt,
stream: false,
options: {
temperature: 0.9,
top_p: 0.9,
num_predict: 200,
repeat_penalty: 1.1,
}
}),
signal: controller.signal
})
clearTimeout(timeoutId)
if (!response.ok) {
throw new Error(`Ollama API error: ${response.status}`)
}
const data = await response.json()
const answer = data.response || 'エラーが発生しました'
console.log('AI response received:', answer)
// Save conversation to ATProto
try {
const now = new Date()
const timestamp = now.toISOString()
const rkey = timestamp.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', '') || ''
// 1. Save question record
const questionRecord = {
$type: 'ai.syui.log.chat',
post: {
url: currentUrl,
slug: postSlug,
title: postTitle,
date: timestamp,
tags: [],
language: "ja"
},
type: "question",
text: question,
author: {
did: user.did,
handle: user.handle,
displayName: user.displayName || user.handle,
avatar: user.avatar
},
createdAt: timestamp
}
await agent.api.com.atproto.repo.putRecord({
repo: user.did,
collection: 'ai.syui.log.chat',
rkey: rkey,
record: questionRecord
})
// 2. Save answer record
const answerRkey = rkey + '-answer'
const answerRecord = {
$type: 'ai.syui.log.chat',
post: {
url: currentUrl,
slug: postSlug,
title: postTitle,
date: timestamp,
tags: [],
language: "ja"
},
type: "answer",
text: answer,
author: {
did: adminData.did,
handle: adminData.profile?.handle,
displayName: adminData.profile?.displayName,
avatar: adminData.profile?.avatar
},
createdAt: timestamp
}
await agent.api.com.atproto.repo.putRecord({
repo: user.did,
collection: 'ai.syui.log.chat',
rkey: answerRkey,
record: answerRecord
})
console.log('Question and answer saved to ATProto')
// Refresh chat records after saving
setTimeout(() => {
fetchUserChatRecords()
}, 1000)
} catch (saveError) {
console.error('Failed to save conversation:', saveError)
}
// Send response to blog
window.dispatchEvent(new CustomEvent('aiResponseReceived', {
detail: {
question: question,
answer: answer,
timestamp: new Date().toISOString(),
aiProfile: adminData?.profile ? {
did: adminData.did,
handle: adminData.profile.handle,
displayName: adminData.profile.displayName,
avatar: adminData.profile.avatar
} : null
}
}))
} catch (error) {
console.error('Failed to process AI question:', error)
// Send error response to blog
window.dispatchEvent(new CustomEvent('aiResponseReceived', {
detail: {
question: question,
answer: 'エラーが発生しました。もう一度お試しください。',
timestamp: new Date().toISOString(),
aiProfile: adminData?.profile ? {
did: adminData.did,
handle: adminData.profile.handle,
displayName: adminData.profile.displayName,
avatar: adminData.profile.avatar
} : null
}
}))
}
}
}
const dispatchAIProfileLoaded = () => {
if (adminData?.profile) {
window.dispatchEvent(new CustomEvent('aiProfileLoaded', {
detail: {
did: adminData.did,
handle: adminData.profile.handle,
displayName: adminData.profile.displayName,
avatar: adminData.profile.avatar
}
}))
}
}
// Listen for questions from blog
window.addEventListener('postAIQuestion', handleAIQuestion)
// Dispatch AI profile when adminData is available
if (adminData?.profile) {
dispatchAIProfileLoaded()
}
return () => {
window.removeEventListener('postAIQuestion', handleAIQuestion)
}
}, [adminData, user, agent])
// Handle OAuth callback
if (window.location.search.includes('code=')) {
return
読み込み中...
エラー: {error}
{retryCount > 0 && (自動リトライ中... ({retryCount}/3)
)}