diff --git a/my-blog/static/css/style.css b/my-blog/static/css/style.css index 972f0b6..f181ac1 100644 --- a/my-blog/static/css/style.css +++ b/my-blog/static/css/style.css @@ -328,7 +328,7 @@ a.view-markdown:any-link { /* Article */ .article-container { - display: grid; + /* display: grid; */ grid-template-columns: 1fr 240px; gap: 40px; max-width: 1000px; diff --git a/oauth/src/App.css b/oauth/src/App.css index 4c96588..5205fdf 100644 --- a/oauth/src/App.css +++ b/oauth/src/App.css @@ -1126,4 +1126,84 @@ body { clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; +} + +/* Chat Conversation Styles */ +.chat-conversation { + margin-bottom: 32px; + padding-bottom: 24px; + border-bottom: 1px solid var(--border); +} + +.chat-conversation:last-child { + border-bottom: none; +} + +.chat-message.comment-style { + background: var(--background); + border: 1px solid var(--border); + border-radius: 8px; + padding: 16px; + margin-bottom: 12px; +} + +.chat-message.user-message.comment-style { + border-left: 4px solid var(--primary); +} + +.chat-message.ai-message.comment-style { + border-left: 4px solid #ffdd00; + background: #faf8ff; +} + +.message-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; +} + +.message-header .avatar { + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--background-secondary); + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + border: 1px solid var(--border); + flex-shrink: 0; +} + +.message-header .user-info { + flex: 1; +} + +.message-header .display-name { + font-weight: 600; + color: var(--text); + font-size: 15px; +} + +.message-header .handle { + color: var(--text-secondary); + font-size: 13px; +} + +.message-header .timestamp { + color: var(--text-secondary); + font-size: 12px; + margin-top: 2px; +} + +.message-content { + color: var(--text); + line-height: 1.5; + white-space: pre-wrap; + word-wrap: break-word; +} + +.record-actions { + flex-shrink: 0; } \ No newline at end of file diff --git a/oauth/src/App.jsx b/oauth/src/App.jsx index dd86627..5a1aa93 100644 --- a/oauth/src/App.jsx +++ b/oauth/src/App.jsx @@ -1,4 +1,5 @@ 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' @@ -14,6 +15,8 @@ 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) @@ -22,6 +25,67 @@ export default function App() { 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]) + // Event listeners for blog communication useEffect(() => { // Clear OAuth completion flag once app is loaded @@ -87,31 +151,80 @@ Answer:` // Save conversation to ATProto try { - const timestamp = new Date().toISOString() - const conversationRecord = { - repo: user.did, - collection: 'ai.syui.log.chat', - record: { - type: 'ai.syui.log.chat', - question: question, - answer: answer, - user: user ? { - did: user.did, - handle: user.handle, - displayName: user.displayName || user.handle - } : null, - ai: { - did: adminData.did, - handle: adminData.profile?.handle, - displayName: adminData.profile?.displayName - }, - timestamp: timestamp, - createdAt: timestamp - } + 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.com.atproto.repo.putRecord(conversationRecord) - console.log('Conversation saved to ATProto') + 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) } @@ -318,6 +431,8 @@ Answer:` commentRecords={commentRecords} userComments={userComments} chatRecords={chatRecords} + userChatRecords={userChatRecords} + userChatLoading={userChatLoading} baseRecords={adminData.records} apiConfig={adminData.apiConfig} pageContext={pageContext} @@ -326,6 +441,7 @@ Answer:` onRecordDeleted={() => { refreshAdminData?.() refreshUserData?.() + fetchUserChatRecords?.() }} /> diff --git a/oauth/src/components/ChatRecordList.jsx b/oauth/src/components/ChatRecordList.jsx new file mode 100644 index 0000000..c1783c9 --- /dev/null +++ b/oauth/src/components/ChatRecordList.jsx @@ -0,0 +1,133 @@ +import React from 'react' + +export default function ChatRecordList({ chatPairs, apiConfig, user = null, agent = null, onRecordDeleted = null }) { + if (!chatPairs || chatPairs.length === 0) { + return ( +
+

チャット履歴がありません

+
+ ) + } + + const handleDelete = async (chatPair) => { + if (!user || !agent || !chatPair.question?.uri) return + + const confirmed = window.confirm('この会話を削除しますか?') + if (!confirmed) return + + try { + // Delete question record + if (chatPair.question?.uri) { + const questionUriParts = chatPair.question.uri.split('/') + await agent.api.com.atproto.repo.deleteRecord({ + repo: questionUriParts[2], + collection: questionUriParts[3], + rkey: questionUriParts[4] + }) + } + + // Delete answer record if exists + if (chatPair.answer?.uri) { + const answerUriParts = chatPair.answer.uri.split('/') + await agent.api.com.atproto.repo.deleteRecord({ + repo: answerUriParts[2], + collection: answerUriParts[3], + rkey: answerUriParts[4] + }) + } + + if (onRecordDeleted) { + onRecordDeleted() + } + } catch (error) { + alert(`削除に失敗しました: ${error.message}`) + } + } + + const canDelete = (chatPair) => { + return user && agent && chatPair.question?.uri && chatPair.question.value.author?.did === user.did + } + + return ( +
+ {chatPairs.map((chatPair, i) => ( +
+ {/* Question */} + {chatPair.question && ( +
+
+ {chatPair.question.value.author?.avatar ? ( + {`${chatPair.question.value.author.displayName + ) : ( +
+ {(chatPair.question.value.author?.displayName || chatPair.question.value.author?.handle || '?').charAt(0).toUpperCase()} +
+ )} +
+
{chatPair.question.value.author?.displayName || chatPair.question.value.author?.handle}
+
@{chatPair.question.value.author?.handle}
+
{new Date(chatPair.question.value.createdAt).toLocaleString()}
+
+ {canDelete(chatPair) && ( +
+ +
+ )} +
+
{chatPair.question.value.text}
+
+ )} + + {/* Answer */} + {chatPair.answer && ( +
+
+ {chatPair.answer.value.author?.avatar ? ( + {`${chatPair.answer.value.author.displayName + ) : ( +
+ {(chatPair.answer.value.author?.displayName || chatPair.answer.value.author?.handle || 'AI').charAt(0).toUpperCase()} +
+ )} +
+
{chatPair.answer.value.author?.displayName || chatPair.answer.value.author?.handle}
+
@{chatPair.answer.value.author?.handle}
+
{new Date(chatPair.answer.value.createdAt).toLocaleString()}
+
+
+
{chatPair.answer.value.text}
+
+ )} + + {/* Post metadata */} + {chatPair.question?.value.post?.url && ( +
+ + {chatPair.question.value.post.url} + +
+ )} +
+ ))} +
+ ) +} \ No newline at end of file diff --git a/oauth/src/components/RecordTabs.jsx b/oauth/src/components/RecordTabs.jsx index c3af86f..8c3e581 100644 --- a/oauth/src/components/RecordTabs.jsx +++ b/oauth/src/components/RecordTabs.jsx @@ -1,8 +1,9 @@ import React, { useState } from 'react' import RecordList from './RecordList.jsx' +import ChatRecordList from './ChatRecordList.jsx' import LoadingSkeleton from './LoadingSkeleton.jsx' -export default function RecordTabs({ langRecords, commentRecords, userComments, chatRecords, baseRecords, apiConfig, pageContext, user = null, agent = null, onRecordDeleted = null }) { +export default function RecordTabs({ langRecords, commentRecords, userComments, chatRecords, userChatRecords, userChatLoading, baseRecords, apiConfig, pageContext, user = null, agent = null, onRecordDeleted = null }) { const [activeTab, setActiveTab] = useState('lang') // Filter records based on page context @@ -51,7 +52,7 @@ export default function RecordTabs({ langRecords, commentRecords, userComments, className={`tab-btn ${activeTab === 'collection' ? 'active' : ''}`} onClick={() => setActiveTab('collection')} > - Posts ({filteredBaseRecords.length}) + Posts ({userChatRecords?.length || 0})