4 Commits

Author SHA1 Message Date
1b7d37243c fix 2025-06-19 15:17:51 +09:00
50516fee21 fix 2025-06-19 15:09:35 +09:00
01c4c543fc fix 2025-06-19 15:02:54 +09:00
ca31760728 fix hugo callback 2025-06-19 14:48:54 +09:00
9 changed files with 350 additions and 75 deletions

View File

@@ -253,6 +253,24 @@ function setupAskAIEventListeners() {
handleAIResponse(event.detail); handleAIResponse(event.detail);
}); });
// Listen for OAuth callback completion from iframe
window.addEventListener('message', function(event) {
if (event.data.type === 'oauth_success') {
console.log('Received OAuth success message:', event.data);
// Close any OAuth popups/iframes
const oauthFrame = document.getElementById('oauth-frame');
if (oauthFrame) {
oauthFrame.remove();
}
// Reload the page to refresh OAuth app state
setTimeout(() => {
window.location.reload();
}, 500);
}
});
// Track IME composition state // Track IME composition state
let isComposing = false; let isComposing = false;
const aiQuestionInput = document.getElementById('aiQuestion'); const aiQuestionInput = document.getElementById('aiQuestion');

View File

@@ -44,7 +44,7 @@ body {
.oauth-header-content { .oauth-header-content {
display: flex; display: flex;
justify-content: flex-start; justify-content: center;
align-items: center; align-items: center;
max-width: 800px; max-width: 800px;
margin: 0 auto; margin: 0 auto;
@@ -52,6 +52,10 @@ body {
width: 100%; width: 100%;
} }
.oauth-header-content:has(.oauth-user-profile) {
justify-content: space-between;
}
.oauth-app-title { .oauth-app-title {
font-size: 20px; font-size: 20px;
font-weight: 800; font-weight: 800;
@@ -62,7 +66,80 @@ body {
display: flex; display: flex;
gap: 8px; gap: 8px;
align-items: center; align-items: center;
width: 100%; flex: 1;
}
/* When user is logged in, actions take normal space */
.oauth-header-content:has(.oauth-user-profile) .oauth-header-actions {
flex: 0 0 auto;
}
/* OAuth User Profile in Header */
.oauth-user-profile {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.profile-avatar-section {
flex-shrink: 0;
}
.profile-avatar-section .profile-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
object-fit: cover;
border: 2px solid var(--border);
}
.profile-avatar-fallback {
width: 48px;
height: 48px;
border-radius: 50%;
background: var(--background-secondary);
border: 2px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 700;
color: var(--text-secondary);
}
.profile-info {
flex: 1;
min-width: 0;
}
.profile-display-name {
font-size: 18px;
font-weight: 700;
color: var(--text);
margin-bottom: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.profile-handle {
font-size: 14px;
color: var(--text-secondary);
margin-bottom: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.profile-did {
font-size: 11px;
color: var(--text-secondary);
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
opacity: 0.7;
} }
/* Buttons */ /* Buttons */
@@ -128,6 +205,7 @@ body {
padding: 0; padding: 0;
gap: 0; gap: 0;
width: 100%; width: 100%;
max-width: 400px;
} }
.auth-section.search-bar-layout .handle-input { .auth-section.search-bar-layout .handle-input {
@@ -465,7 +543,7 @@ body {
} }
.user-message .message-content { .user-message .message-content {
color: white; color: #000;
} }
.question-form { .question-form {
@@ -647,6 +725,41 @@ body {
.chat-container { .chat-container {
height: 300px; height: 300px;
} }
/* OAuth User Profile Mobile */
.oauth-user-profile {
gap: 8px;
}
.profile-avatar-section .profile-avatar,
.profile-avatar-fallback {
width: 36px;
height: 36px;
font-size: 14px;
}
.profile-display-name {
font-size: 14px;
}
.profile-handle {
font-size: 12px;
}
.profile-did {
font-size: 9px;
}
.oauth-header-content {
flex-direction: column;
gap: 12px;
align-items: flex-start;
}
.oauth-header-actions {
width: 100%;
justify-content: center;
}
} }
/* Avatar Styles */ /* Avatar Styles */

View File

@@ -69,8 +69,32 @@ export default function App() {
if (isLoading) { if (isLoading) {
return ( return (
<div style={{ padding: '20px', textAlign: 'center' }}> <div style={{
<p>読み込み中...</p> display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '200px',
padding: '40px',
textAlign: 'center'
}}>
<div style={{
width: '40px',
height: '40px',
border: '4px solid #f3f3f3',
borderTop: '4px solid #667eea',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
marginBottom: '16px'
}} />
<p style={{ color: '#666', margin: 0 }}>読み込み中...</p>
<style jsx>{`
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`}</style>
</div> </div>
) )
} }
@@ -115,6 +139,34 @@ export default function App() {
<div className="app"> <div className="app">
<header className="oauth-app-header"> <header className="oauth-app-header">
<div className="oauth-header-content"> <div className="oauth-header-content">
{user && (
<div className="oauth-user-profile">
<div className="profile-avatar-section">
{user.avatar ? (
<img
src={user.avatar}
alt={user.displayName || user.handle}
className="profile-avatar"
/>
) : (
<div className="profile-avatar-fallback">
{(user.displayName || user.handle || '?').charAt(0).toUpperCase()}
</div>
)}
</div>
<div className="profile-info">
<div className="profile-display-name">
{user.displayName || user.handle}
</div>
<div className="profile-handle">
@{user.handle}
</div>
<div className="profile-did">
{user.did}
</div>
</div>
</div>
)}
<div className="oauth-header-actions"> <div className="oauth-header-actions">
<AuthButton <AuthButton
user={user} user={user}

View File

@@ -63,6 +63,17 @@ export const atproto = {
}, },
async getProfile(bsky, actor) { async getProfile(bsky, actor) {
// Skip test DIDs
if (actor && actor.includes('test-')) {
logger.log('Skipping profile fetch for test DID:', actor)
return {
did: actor,
handle: 'test.user',
displayName: 'Test User',
avatar: null
}
}
return await request(`${bsky}/xrpc/${ENDPOINTS.getProfile}?actor=${actor}`) return await request(`${bsky}/xrpc/${ENDPOINTS.getProfile}?actor=${actor}`)
}, },

View File

@@ -1,54 +1,36 @@
import React, { useEffect, useState } from 'react' import React from 'react'
export default function OAuthCallback({ onAuthSuccess }) {
const [status, setStatus] = useState('OAuth認証処理中...')
useEffect(() => {
handleCallback()
}, [])
const handleCallback = async () => {
try {
// BrowserOAuthClientが自動的にコールバックを処理します
// URLのパラメータを確認して成功を通知
const urlParams = new URLSearchParams(window.location.search)
const code = urlParams.get('code')
const error = urlParams.get('error')
if (error) {
throw new Error(`OAuth error: ${error}`)
}
if (code) {
setStatus('認証成功!元のページに戻ります...')
// Get the referring page or use root
const referrer = document.referrer || window.location.origin
const returnUrl = referrer.includes('/oauth/callback') ? window.location.origin : referrer
// 少し待ってから元のページにリダイレクト
setTimeout(() => {
window.location.href = returnUrl
}, 1500)
} else {
setStatus('認証情報が見つかりません')
}
} catch (error) {
console.error('Callback error:', error)
setStatus('認証エラー: ' + error.message)
}
}
export default function OAuthCallback() {
return ( return (
<div style={{ padding: '20px', textAlign: 'center' }}> <div style={{
<h2>OAuth認証</h2> display: 'flex',
<p>{status}</p> flexDirection: 'column',
{status.includes('エラー') && ( alignItems: 'center',
<button onClick={() => window.location.href = '/'}> justifyContent: 'center',
メインページに戻る minHeight: '50vh',
</button> padding: '40px',
)} textAlign: 'center'
}}>
<div style={{
width: '50px',
height: '50px',
border: '4px solid #f3f3f3',
borderTop: '4px solid #667eea',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
marginBottom: '20px'
}} />
<h2 style={{ color: '#333', marginBottom: '12px' }}>OAuth認証処理中...</h2>
<p style={{ color: '#666', fontSize: '14px' }}>
認証が完了しましたら自動で元のページに戻ります
</p>
<style jsx>{`
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`}</style>
</div> </div>
) )
} }

View File

@@ -9,8 +9,12 @@ export function useAskAI(adminData, userProfile, agent) {
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [chatHistory, setChatHistory] = useState([]) const [chatHistory, setChatHistory] = useState([])
// ask-AIサーバーのURL環境変数から取得、フォールバック付き // AI設定を環境変数から取得
const askAIUrl = import.meta.env.VITE_ASK_AI_URL || 'http://localhost:3000/ask' 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 askQuestion = async (question) => { const askQuestion = async (question) => {
if (!question.trim()) return if (!question.trim()) return
@@ -19,28 +23,47 @@ export function useAskAI(adminData, userProfile, agent) {
setError(null) setError(null)
try { try {
logger.log('Sending question to ask-AI:', question) logger.log('Sending question to Ollama:', question)
// ask-AIサーバーにリクエスト送信 // Ollamaに直接リクエスト送信oauth_oldと同じ方式
const response = await fetch(askAIUrl, { const prompt = `${aiConfig.systemPrompt}
Question: ${question}
Answer:`
// Add timeout to fetch request
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 30000) // 30 second timeout
const response = await fetch(`${aiConfig.host}/api/generate`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Origin': 'https://syui.ai',
}, },
body: JSON.stringify({ body: JSON.stringify({
question: question.trim(), model: aiConfig.model,
context: { prompt: prompt,
url: window.location.href, stream: false,
timestamp: new Date().toISOString() options: {
temperature: 0.9,
top_p: 0.9,
num_predict: 200, // Longer responses for better answers
repeat_penalty: 1.1,
} }
}),
signal: controller.signal
}) })
})
clearTimeout(timeoutId)
if (!response.ok) { if (!response.ok) {
throw new Error(`ask-AI server error: ${response.status}`) throw new Error(`Ollama API error: ${response.status}`)
} }
const aiResponse = await response.json() const data = await response.json()
const aiResponse = { answer: data.response || 'エラーが発生しました' }
logger.log('Received AI response:', aiResponse) logger.log('Received AI response:', aiResponse)
// AI回答をチャット履歴に追加 // AI回答をチャット履歴に追加
@@ -81,7 +104,17 @@ export function useAskAI(adminData, userProfile, agent) {
} catch (err) { } catch (err) {
logError(err, 'useAskAI.askQuestion') logError(err, 'useAskAI.askQuestion')
setError(getErrorMessage(err))
let errorMessage = 'AI応答の生成に失敗しました'
if (err.name === 'AbortError') {
errorMessage = 'AI応答がタイムアウトしました30秒'
} else if (err.message.includes('Ollama API error')) {
errorMessage = `Ollama API エラー: ${err.message}`
} else if (err.message.includes('Failed to fetch')) {
errorMessage = 'AI サーバーに接続できませんでした'
}
setError(errorMessage)
throw err throw err
} finally { } finally {
setLoading(false) setLoading(false)

View File

@@ -18,6 +18,32 @@ export function useAuth() {
if (authResult) { if (authResult) {
setUser(authResult.user) setUser(authResult.user)
setAgent(authResult.agent) setAgent(authResult.agent)
// If we're on callback page and authentication succeeded, notify parent
if (window.location.pathname === '/oauth/callback') {
console.log('OAuth callback completed, notifying parent window')
// Get referrer or use stored return URL
const returnUrl = sessionStorage.getItem('oauth_return_url') ||
document.referrer ||
window.location.origin
sessionStorage.removeItem('oauth_return_url')
// Notify parent window if in iframe, otherwise redirect directly
if (window.parent !== window) {
window.parent.postMessage({
type: 'oauth_success',
returnUrl: returnUrl,
user: authResult.user
}, '*')
} else {
// Direct redirect
setTimeout(() => {
window.location.href = returnUrl
}, 1000)
}
}
} }
} catch (error) { } catch (error) {
console.error('Auth initialization failed:', error) console.error('Auth initialization failed:', error)
@@ -27,6 +53,11 @@ export function useAuth() {
} }
const login = async (handle) => { const login = async (handle) => {
// Store current page URL for post-auth redirect
if (window.location.pathname !== '/oauth/callback') {
sessionStorage.setItem('oauth_return_url', window.location.href)
}
await oauthService.login(handle) await oauthService.login(handle)
} }

View File

@@ -65,6 +65,8 @@ export class OAuthService {
async processSession(session) { async processSession(session) {
const did = session.sub || session.did const did = session.sub || session.did
let handle = session.handle || 'unknown' let handle = session.handle || 'unknown'
let displayName = null
let avatar = null
// Create Agent directly with session (per official docs) // Create Agent directly with session (per official docs)
try { try {
@@ -77,21 +79,43 @@ export class OAuthService {
}) })
} }
this.sessionInfo = { did, handle } // Get profile information using authenticated agent
// Skip test DIDs
// Resolve handle if missing if (this.agent && did && !did.includes('test-')) {
if (handle === 'unknown' && this.agent) {
try { try {
await new Promise(resolve => setTimeout(resolve, 300)) await new Promise(resolve => setTimeout(resolve, 300))
const profile = await this.agent.getProfile({ actor: did }) const profile = await this.agent.getProfile({ actor: did })
handle = profile.data.handle handle = profile.data.handle || handle
this.sessionInfo.handle = handle displayName = profile.data.displayName || null
avatar = profile.data.avatar || null
console.log('Profile fetched from session:', {
did,
handle,
displayName,
avatar: avatar ? 'present' : 'none'
})
} catch (error) { } catch (error) {
console.log('Failed to resolve handle:', error) console.log('Failed to get profile from session:', error)
// Keep the basic info we have
} }
} else if (did && did.includes('test-')) {
console.log('Skipping profile fetch for test DID:', did)
} }
return { did, handle } this.sessionInfo = {
did,
handle,
displayName,
avatar
}
return {
did,
handle,
displayName,
avatar
}
} }
async login(handle) { async login(handle) {

View File

@@ -33,6 +33,12 @@ async function getDid(handle) {
// DIDからプロフィール情報を取得 // DIDからプロフィール情報を取得
async function getProfile(did, handle) { async function getProfile(did, handle) {
// Skip test DIDs
if (did && did.includes('test-')) {
logger.log('Skipping profile fetch for test DID:', did)
return null
}
try { try {
// Determine which public API to use based on handle // Determine which public API to use based on handle
const pds = await getPdsFromHandle(handle) const pds = await getPdsFromHandle(handle)
@@ -81,6 +87,11 @@ async function fetchFreshAvatar(handle, did) {
// プロフィール取得 // プロフィール取得
const profile = await getProfile(actualDid, handle) const profile = await getProfile(actualDid, handle)
if (!profile) {
// Test DID or profile fetch failed
return null
}
const avatarUrl = profile.avatar || null const avatarUrl = profile.avatar || null
// キャッシュに保存 // キャッシュに保存