fix color

This commit is contained in:
2025-06-13 20:15:20 +09:00
parent 36863e4d9f
commit 33c166fa0c
9 changed files with 551 additions and 117 deletions

View File

@ -22,3 +22,7 @@ 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

View File

@ -1,7 +1,16 @@
/* Theme Colors */
:root {
--theme-color: #FF4500;
--white: #fff;
--light-gray: #aaa;
--dark-gray: #666;
--background: #fff;
}
.app {
min-height: 100vh;
background: linear-gradient(180deg, #f8f9fa 0%, #ffffff 100%);
color: #333333;
background: linear-gradient(180deg, #f8f9fa 0%, var(--background) 100%);
color: var(--dark-gray);
}
.app-header {
@ -41,15 +50,15 @@
}
.nav-button.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: 1px solid #667eea;
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4);
background: var(--theme-color);
color: var(--white);
border: 1px solid var(--theme-color);
box-shadow: 0 4px 16px rgba(255, 69, 0, 0.4);
}
.nav-button.active:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.5);
box-shadow: 0 6px 20px rgba(255, 69, 0, 0.5);
}
.app-header h1 {
@ -99,9 +108,9 @@
}
.login-button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: 1px solid #667eea;
background: var(--theme-color);
color: var(--white);
border: 1px solid var(--theme-color);
}
.backup-button {
@ -124,7 +133,7 @@
.login-button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
box-shadow: 0 4px 12px rgba(255, 69, 0, 0.4);
}
.backup-button:hover {
@ -268,8 +277,8 @@
}
.atproto-button {
background: #1185fe;
color: white;
background: var(--theme-color);
color: var(--white);
border: none;
padding: 12px 24px;
border-radius: 6px;
@ -281,9 +290,9 @@
}
.atproto-button:hover {
background: #0d6efd;
filter: brightness(1.1);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(17, 133, 254, 0.4);
box-shadow: 0 4px 12px rgba(255, 69, 0, 0.4);
}
.username-input-section {
@ -407,8 +416,8 @@
}
.post-button {
background: #28a745;
color: white;
background: var(--theme-color);
color: var(--white);
border: none;
padding: 10px 20px;
border-radius: 6px;
@ -419,9 +428,9 @@
}
.post-button:hover:not(:disabled) {
background: #218838;
filter: brightness(1.1);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(40, 167, 69, 0.4);
box-shadow: 0 4px 12px rgba(255, 69, 0, 0.4);
}
.post-button:disabled {
@ -455,8 +464,8 @@
}
.comments-toggle-button {
background: #1185fe;
color: white;
background: var(--theme-color);
color: var(--white);
border: none;
padding: 8px 16px;
border-radius: 6px;
@ -467,9 +476,9 @@
}
.comments-toggle-button:hover {
background: #0d6efd;
filter: brightness(1.1);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(17, 133, 254, 0.4);
box-shadow: 0 4px 12px rgba(255, 69, 0, 0.4);
}
.comment-item {
@ -714,8 +723,8 @@
/* JSON Display Styles */
.json-button {
background: #4caf50;
color: white;
background: var(--theme-color);
color: var(--white);
border: none;
padding: 4px 8px;
border-radius: 4px;
@ -726,7 +735,7 @@
}
.json-button:hover {
background: #45a049;
filter: brightness(1.1);
transform: scale(1.05);
}
@ -759,4 +768,108 @@
color: #333;
max-height: 400px;
overflow-y: auto;
}
/* Tab Navigation */
.tab-navigation {
display: flex;
border-bottom: 2px solid #e1e5e9;
margin-bottom: 20px;
}
.tab-button {
background: none;
border: none;
padding: 12px 20px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
color: #656d76;
border-bottom: 2px solid transparent;
transition: all 0.2s;
}
.tab-button:hover {
color: var(--theme-color);
background: #f6f8fa;
}
.tab-button.active {
color: var(--theme-color);
border-bottom-color: var(--theme-color);
background: #f6f8fa;
}
/* AI Chat History */
.ai-chat-list {
max-width: 100%;
border: 1px solid #ddd;
border-radius: 8px;
padding: 20px;
}
.chat-item {
border: 1px solid #d1d9e0;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
background: #ffffff;
}
.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.chat-actions {
display: flex;
align-items: center;
gap: 8px;
}
.chat-type-button {
background: var(--theme-color);
color: var(--white);
border: none;
padding: 4px 8px;
border-radius: 4px;
cursor: default;
font-size: 12px;
font-weight: 500;
margin-left: 4px;
}
.chat-type-text {
font-size: 16px;
margin-left: 4px;
}
.chat-date {
color: #656d76;
font-size: 12px;
}
.chat-content {
background: #f6f8fa;
padding: 12px;
border-radius: 6px;
border-left: 4px solid #d1d9e0;
margin-bottom: 8px;
white-space: pre-wrap;
line-height: 1.5;
}
.chat-meta {
font-size: 11px;
color: #656d76;
}
.no-chat {
text-align: center;
padding: 40px 20px;
color: #656d76;
font-style: italic;
}

View File

@ -46,6 +46,8 @@ function App() {
const [isPostingUserList, setIsPostingUserList] = useState(false);
const [userListRecords, setUserListRecords] = useState<any[]>([]);
const [showJsonFor, setShowJsonFor] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'comments' | 'ai-chat'>('comments');
const [aiChatHistory, setAiChatHistory] = useState<any[]>([]);
useEffect(() => {
// Setup Jetstream WebSocket for real-time comments (optional)
@ -151,6 +153,9 @@ function App() {
console.log('OAuth session found, loading all comments...');
loadAllComments();
// Load AI chat history
loadAiChatHistory(userProfile.did);
// Load user list records if admin
if (userProfile.did === appConfig.adminDid) {
loadUserListRecords();
@ -221,6 +226,50 @@ function App() {
return `https://via.placeholder.com/48x48/1185fe/ffffff?text=${initial}`;
};
const loadAiChatHistory = async (did: string) => {
try {
console.log('Loading AI chat history for DID:', did);
const agent = atprotoOAuthService.getAgent();
if (!agent) {
console.log('No agent available');
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 || [];
// Filter out old records with invalid AI profile data (temporary fix for migration)
const validRecords = chatRecords.filter(record => {
if (record.value.answer) {
// This is an AI answer - check if it has valid AI profile
return record.value.author?.handle &&
record.value.author?.handle !== 'ai-assistant' &&
record.value.author?.displayName !== 'AI Assistant';
}
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
const sortedRecords = validRecords.sort((a, b) =>
new Date(a.value.createdAt).getTime() - new Date(b.value.createdAt).getTime()
);
setAiChatHistory(sortedRecords);
} catch (err) {
console.error('Failed to load AI chat history:', err);
setAiChatHistory([]);
}
};
const loadUserComments = async (did: string) => {
try {
console.log('Loading comments for DID:', did);
@ -305,7 +354,7 @@ function App() {
if (user.did && user.did.includes('-placeholder')) {
console.log(`Resolving placeholder DID for ${user.handle}`);
try {
const profileResponse = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(user.handle)}`);
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) {
@ -456,7 +505,7 @@ function App() {
if (!record.value.author?.avatar && record.value.author?.handle) {
try {
// Public API でプロフィール取得
const profileResponse = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(record.value.author.handle)}`);
const profileResponse = await fetch(`${appConfig.bskyPublicApi}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(record.value.author.handle)}`);
if (profileResponse.ok) {
const profileData = await profileResponse.json();
@ -683,7 +732,7 @@ function App() {
try {
// Public APIでプロフィールを取得してDIDを解決
const profileResponse = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`);
const profileResponse = await fetch(`${appConfig.bskyPublicApi}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`);
if (profileResponse.ok) {
const profileData = await profileResponse.json();
if (profileData.did) {
@ -974,11 +1023,30 @@ function App() {
</div>
)}
{/* Tab Navigation */}
<div className="tab-navigation">
<button
className={`tab-button ${activeTab === 'comments' ? 'active' : ''}`}
onClick={() => setActiveTab('comments')}
>
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>
)}
</div>
{/* Comments List */}
<div className="comments-list">
<div className="comments-header">
<h3>Comments</h3>
</div>
{activeTab === 'comments' && (
<div className="comments-list">
<div className="comments-header">
<h3>Comments</h3>
</div>
{comments.filter(shouldShowComment).length === 0 ? (
<p className="no-comments">
{appConfig.rkey ? `No comments for this post yet` : `No comments yet`}
@ -988,9 +1056,25 @@ function App() {
<div key={index} className="comment-item">
<div className="comment-header">
<img
src={record.value.author?.avatar || generatePlaceholderAvatar(record.value.author?.handle || 'unknown')}
src={generatePlaceholderAvatar(record.value.author?.handle || 'unknown')}
alt="User Avatar"
className="comment-avatar"
ref={(img) => {
// Fetch fresh avatar from API when component mounts
if (img && record.value.author?.did) {
fetch(`${appConfig.bskyPublicApi}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(record.value.author.did)}`)
.then(res => res.json())
.then(data => {
if (data.avatar && img) {
img.src = data.avatar;
}
})
.catch(err => {
console.warn('Failed to fetch fresh avatar:', err);
// Keep placeholder on error
});
}
}}
/>
<div className="comment-author-info">
<span className="comment-author">
@ -1047,7 +1131,92 @@ function App() {
</div>
))
)}
</div>
</div>
)}
{/* AI Chat History List */}
{activeTab === 'ai-chat' && user && (
<div className="ai-chat-list">
<div className="chat-header">
<h3>AI Chat History</h3>
</div>
{aiChatHistory.length === 0 ? (
<p className="no-chat">No AI conversations yet. Start chatting with Ask AI!</p>
) : (
aiChatHistory.map((record, index) => (
<div key={index} className="chat-item">
<div className="chat-header">
<img
src={generatePlaceholderAvatar(record.value.author?.handle || 'unknown')}
alt="User Avatar"
className="comment-avatar"
ref={(img) => {
// Fetch fresh avatar from API when component mounts
if (img && record.value.author?.did) {
fetch(`${appConfig.bskyPublicApi}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(record.value.author.did)}`)
.then(res => res.json())
.then(data => {
if (data.avatar && img) {
img.src = data.avatar;
}
})
.catch(err => {
console.warn('Failed to fetch fresh avatar:', err);
// Keep placeholder on error
});
}
}}
/>
<div className="comment-author-info">
<span className="comment-author">
{record.value.author?.displayName || record.value.author?.handle || 'unknown'}
</span>
<a
href={generateProfileUrl(record.value.author?.handle || '', record.value.author?.did || '')}
target="_blank"
rel="noopener noreferrer"
className="comment-handle"
>
@{record.value.author?.handle || 'unknown'}
</a>
</div>
<span className="comment-date">
{new Date(record.value.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>
<button className="chat-type-button">
{record.value.question ? 'Question' : 'Answer'}
</button>
</div>
</div>
<div className="comment-content">
{record.value.question || record.value.answer}
</div>
<div className="comment-meta">
<small>{record.uri}</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>
))
)}
</div>
)}
{/* Comment Form - Only show on post pages */}
{user && appConfig.rkey && (

View File

@ -0,0 +1,21 @@
// Cloudflare Access対応版の例
const response = await fetch(`${aiConfig.host}/api/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// Cloudflare Access Service Token
'CF-Access-Client-Id': import.meta.env.VITE_CF_ACCESS_CLIENT_ID,
'CF-Access-Client-Secret': import.meta.env.VITE_CF_ACCESS_CLIENT_SECRET,
},
body: JSON.stringify({
model: aiConfig.model,
prompt: prompt,
stream: false,
options: {
temperature: 0.9,
top_p: 0.9,
num_predict: 80,
repeat_penalty: 1.1,
}
}),
});

View File

@ -23,11 +23,15 @@ export const AIChat: React.FC<AIChatProps> = ({ user, isEnabled }) => {
host: import.meta.env.VITE_AI_HOST || 'https://ollama.syui.ai',
systemPrompt: import.meta.env.VITE_AI_SYSTEM_PROMPT || 'You are a helpful AI assistant trained on this blog\'s content.',
aiDid: import.meta.env.VITE_AI_DID || 'did:plc:uqzpqmrjnptsxezjx4xuh2mn',
bskyPublicApi: import.meta.env.VITE_BSKY_PUBLIC_API || 'https://public.api.bsky.app',
};
// Fetch AI profile on load
useEffect(() => {
const fetchAIProfile = async () => {
console.log('=== AI PROFILE FETCH START ===');
console.log('AI DID:', aiConfig.aiDid);
if (!aiConfig.aiDid) {
console.log('No AI DID configured');
return;
@ -42,51 +46,48 @@ export const AIChat: React.FC<AIChatProps> = ({ user, isEnabled }) => {
console.log('AI profile fetched successfully:', profile.data);
const profileData = {
did: aiConfig.aiDid,
handle: profile.data.handle || 'ai-assistant',
displayName: profile.data.displayName || 'AI Assistant',
avatar: profile.data.avatar || null,
description: profile.data.description || null
handle: profile.data.handle,
displayName: profile.data.displayName,
avatar: profile.data.avatar,
description: profile.data.description
};
console.log('Setting aiProfile to:', profileData);
setAiProfile(profileData);
// Dispatch event to update Ask AI button
window.dispatchEvent(new CustomEvent('aiProfileLoaded', { detail: profileData }));
console.log('=== AI PROFILE FETCH SUCCESS (AGENT) ===');
return;
}
// Fallback to public API
console.log('No agent available, trying public API for AI profile');
const response = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(aiConfig.aiDid)}`);
const response = await fetch(`${aiConfig.bskyPublicApi}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(aiConfig.aiDid)}`);
if (response.ok) {
const profileData = await response.json();
console.log('AI profile fetched via public API:', profileData);
const profile = {
did: aiConfig.aiDid,
handle: profileData.handle || 'ai-assistant',
displayName: profileData.displayName || 'AI Assistant',
avatar: profileData.avatar || null,
description: profileData.description || null
handle: profileData.handle,
displayName: profileData.displayName,
avatar: profileData.avatar,
description: profileData.description
};
console.log('Setting aiProfile to:', profile);
setAiProfile(profile);
// Dispatch event to update Ask AI button
window.dispatchEvent(new CustomEvent('aiProfileLoaded', { detail: profile }));
console.log('=== AI PROFILE FETCH SUCCESS (PUBLIC API) ===');
return;
} else {
console.error('Public API failed with status:', response.status);
}
} catch (error) {
console.log('Failed to fetch AI profile, using defaults:', error);
const fallbackProfile = {
did: aiConfig.aiDid,
handle: 'ai-assistant',
displayName: 'AI Assistant',
avatar: null,
description: 'AI assistant for this blog'
};
setAiProfile(fallbackProfile);
// Dispatch event even with fallback profile
window.dispatchEvent(new CustomEvent('aiProfileLoaded', { detail: fallbackProfile }));
console.error('Failed to fetch AI profile:', error);
setAiProfile(null);
}
console.log('=== AI PROFILE FETCH FAILED ===');
};
fetchAIProfile();
@ -97,9 +98,11 @@ export const AIChat: React.FC<AIChatProps> = ({ user, isEnabled }) => {
// Listen for AI question posts from base.html
const handleAIQuestion = async (event: any) => {
if (!user || !event.detail || !event.detail.question || isProcessing) return;
if (!user || !event.detail || !event.detail.question || isProcessing || !aiProfile) return;
console.log('AIChat received question:', event.detail.question);
console.log('Current aiProfile state:', aiProfile);
setIsProcessing(true);
try {
await postQuestionAndGenerateResponse(event.detail.question);
@ -120,10 +123,10 @@ export const AIChat: React.FC<AIChatProps> = ({ user, isEnabled }) => {
return () => {
window.removeEventListener('postAIQuestion', handleAIQuestion);
};
}, [user, isEnabled, isProcessing]);
}, [user, isEnabled, isProcessing, aiProfile]);
const postQuestionAndGenerateResponse = async (question: string) => {
if (!user || !aiConfig.askAi) return;
if (!user || !aiConfig.askAi || !aiProfile) return;
setIsLoading(true);
@ -232,6 +235,9 @@ Answer:`;
// 5. Save AI response in background
const answerRkey = now.toISOString().replace(/[:.]/g, '-') + '-answer';
console.log('=== SAVING AI ANSWER ===');
console.log('Current aiProfile:', aiProfile);
const answerRecord = {
$type: appConfig.collections.chat,
answer: aiAnswer,
@ -239,11 +245,14 @@ Answer:`;
url: window.location.href,
createdAt: now.toISOString(),
author: {
did: aiConfig.aiDid,
handle: 'AI Assistant',
displayName: 'AI Assistant',
did: aiProfile.did,
handle: aiProfile.handle,
displayName: aiProfile.displayName,
avatar: aiProfile.avatar,
},
};
console.log('Answer record to save:', answerRecord);
// Save to ATProto asynchronously (don't wait for it)
agent.api.com.atproto.repo.putRecord({

View File

@ -13,6 +13,7 @@ export interface AppConfig {
aiProvider: string;
aiModel: string;
aiHost: string;
bskyPublicApi: string;
}
// Generate collection names from host
@ -80,13 +81,15 @@ export function getAppConfig(): AppConfig {
const aiProvider = import.meta.env.VITE_AI_PROVIDER || 'ollama';
const aiModel = import.meta.env.VITE_AI_MODEL || 'gemma2:2b';
const aiHost = import.meta.env.VITE_AI_HOST || 'https://ollama.syui.ai';
const bskyPublicApi = import.meta.env.VITE_BSKY_PUBLIC_API || 'https://public.api.bsky.app';
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 }
ai: { enabled: aiEnabled, askAi: aiAskAi, provider: aiProvider, model: aiModel, host: aiHost },
bskyPublicApi
});
return {
@ -98,7 +101,8 @@ export function getAppConfig(): AppConfig {
aiAskAi,
aiProvider,
aiModel,
aiHost
aiHost,
bskyPublicApi
};
}