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

@ -35,7 +35,8 @@
"Bash(npm run dev:*)", "Bash(npm run dev:*)",
"Bash(./target/release/ailog:*)", "Bash(./target/release/ailog:*)",
"Bash(rg:*)", "Bash(rg:*)",
"Bash(../target/release/ailog build)" "Bash(../target/release/ailog build)",
"Bash(zsh run.zsh:*)"
], ],
"deny": [] "deny": []
} }

121
README.md
View File

@ -4,60 +4,62 @@ AI-powered static blog generator with ATProto integration, part of the ai.ai eco
## 🚀 Quick Start ## 🚀 Quick Start
### Basic Blog Setup ### Development Setup
```bash ```bash
# 1. Initialize a new blog # 1. Clone and setup
ailog init my-blog git clone https://git.syui.ai/ai/log
cd log
# 2. Configure your blog (edit my-blog/config.toml) # 2. Start development services
[site] ./run.zsh serve # Blog development server
title = "My Blog" ./run.zsh c # Cloudflare tunnel (log.syui.ai)
description = "A blog powered by ailog" ./run.zsh o # OAuth web server
base_url = "https://yourdomain.com" ./run.zsh co # Comment system monitor
language = "ja"
[build] # 3. Start Ollama (for Ask AI)
highlight_code = true brew install ollama
minify = false ollama pull gemma2:2b
OLLAMA_ORIGINS="https://log.syui.ai" ollama serve
[ai]
enabled = true
auto_translate = false
comment_moderation = false
ask_ai = true
provider = "ollama"
model = "gemma3:4b"
host = "https://ollama.yourdomain.com"
system_prompt = "You are a helpful AI assistant trained on this blog's content."
ai_did = "did:plc:your-ai-bot-did"
# 3. Build your blog
ailog build
# 4. Serve locally
ailog serve
``` ```
### ATProto Comment System ### Production Deployment
```bash ```bash
# 1. Add OAuth configuration to my-blog/config.toml # 1. Build static site
[oauth] hugo
json = "client-metadata.json"
redirect = "oauth/callback"
admin = "your-did-here"
collection_comment = "ai.syui.log"
collection_user = "ai.syui.log.user"
# 2. Build OAuth app # 2. Deploy to GitHub Pages
ailog oauth build my-blog git add .
git commit -m "Update blog"
git push origin main
# 3. Authenticate with ATProto # 3. Automatic deployment via GitHub Actions
ailog auth init # Site available at: https://yourusername.github.io/repo-name
```
# 4. Start stream monitoring ### ATProto Integration
ailog stream start my-blog
```bash
# 1. OAuth Client Setup (oauth/client-metadata.json)
{
"client_id": "https://log.syui.ai/client-metadata.json",
"client_name": "ai.log Blog System",
"redirect_uris": ["https://log.syui.ai/oauth/callback"],
"scope": "atproto",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"application_type": "web",
"dpop_bound_access_tokens": true
}
# 2. Comment System Configuration
# Collection: ai.syui.log (comments)
# User Management: ai.syui.log.user (registered users)
# 3. Services
./run.zsh o # OAuth authentication server
./run.zsh co # ATProto Jetstream comment monitor
``` ```
### Development with run.zsh ### Development with run.zsh
@ -130,15 +132,30 @@ ai.logは、[Anthropic Docs](https://docs.anthropic.com/)にインスパイア
- **自動TOC**: 右サイドバーに目次を自動生成 - **自動TOC**: 右サイドバーに目次を自動生成
- **レスポンシブ**: モバイル・デスクトップ対応 - **レスポンシブ**: モバイル・デスクトップ対応
### 🤖 AI統合機能 ### 🤖 Ask AI機能 ✅
- **Ask AI**: ローカルLLM(Ollama)による質問応答 - **ローカルAI**: Ollama(gemma2:2b)による質問応答
- トップページでのみ利用可能 - **認証必須**: ATProto OAuth認証でアクセス制御
- atproto OAuth認証必須 - **トップページ限定**: ブログコンテンツに特化した回答
- Cloudflare Tunnel経由でCORS問題解決済み - **CORS解決済み**: OLLAMA_ORIGINS設定でクロスオリジン問題解消
- **自動翻訳**: 日本語↔英語の自動生成 - **プロフィール連携**: AIアバターとしてATProtoプロフィール画像表示
- **AI記事強化**: コンテンツの自動改善 - **レスポンス最適化**: 80文字制限+高いtemperatureで多様な回答
- **AIコメント**: 記事への一言コメント生成 - **ローディング表示**: Font Awesomeアイコンによる一行ローディング
- **カスタマイズ可能なAI設定**: system_prompt、ai_did、プロフィール連携
### 🔧 Ask AI設定方法
```bash
# 1. Ollama設定
brew install ollama
ollama pull gemma2:2b
# 2. CORS設定で起動
OLLAMA_ORIGINS="https://log.syui.ai" ollama serve
# 3. AI DID設定 (my-blog/templates/base.html)
const aiConfig = {
systemPrompt: 'You are a helpful AI assistant.',
aiDid: 'did:plc:your-ai-bot-did'
};
```
### 🌐 分散SNS連携 ### 🌐 分散SNS連携
- **atproto OAuth**: Blueskyアカウントでログイン - **atproto OAuth**: Blueskyアカウントでログイン

View File

@ -22,3 +22,7 @@ VITE_AI_MODEL=gemma3:4b
VITE_AI_HOST=https://ollama.syui.ai VITE_AI_HOST=https://ollama.syui.ai
VITE_AI_SYSTEM_PROMPT="あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。" VITE_AI_SYSTEM_PROMPT="あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"
VITE_AI_DID=did:plc:4hqjfn7m6n5hno3doamuhgef 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 { .app {
min-height: 100vh; min-height: 100vh;
background: linear-gradient(180deg, #f8f9fa 0%, #ffffff 100%); background: linear-gradient(180deg, #f8f9fa 0%, var(--background) 100%);
color: #333333; color: var(--dark-gray);
} }
.app-header { .app-header {
@ -41,15 +50,15 @@
} }
.nav-button.active { .nav-button.active {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: var(--theme-color);
color: white; color: var(--white);
border: 1px solid #667eea; border: 1px solid var(--theme-color);
box-shadow: 0 4px 16px rgba(102, 126, 234, 0.4); box-shadow: 0 4px 16px rgba(255, 69, 0, 0.4);
} }
.nav-button.active:hover { .nav-button.active:hover {
transform: translateY(-2px); 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 { .app-header h1 {
@ -99,9 +108,9 @@
} }
.login-button { .login-button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: var(--theme-color);
color: white; color: var(--white);
border: 1px solid #667eea; border: 1px solid var(--theme-color);
} }
.backup-button { .backup-button {
@ -124,7 +133,7 @@
.login-button:hover { .login-button:hover {
transform: translateY(-2px); 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 { .backup-button:hover {
@ -268,8 +277,8 @@
} }
.atproto-button { .atproto-button {
background: #1185fe; background: var(--theme-color);
color: white; color: var(--white);
border: none; border: none;
padding: 12px 24px; padding: 12px 24px;
border-radius: 6px; border-radius: 6px;
@ -281,9 +290,9 @@
} }
.atproto-button:hover { .atproto-button:hover {
background: #0d6efd; filter: brightness(1.1);
transform: translateY(-2px); 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 { .username-input-section {
@ -407,8 +416,8 @@
} }
.post-button { .post-button {
background: #28a745; background: var(--theme-color);
color: white; color: var(--white);
border: none; border: none;
padding: 10px 20px; padding: 10px 20px;
border-radius: 6px; border-radius: 6px;
@ -419,9 +428,9 @@
} }
.post-button:hover:not(:disabled) { .post-button:hover:not(:disabled) {
background: #218838; filter: brightness(1.1);
transform: translateY(-2px); 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 { .post-button:disabled {
@ -455,8 +464,8 @@
} }
.comments-toggle-button { .comments-toggle-button {
background: #1185fe; background: var(--theme-color);
color: white; color: var(--white);
border: none; border: none;
padding: 8px 16px; padding: 8px 16px;
border-radius: 6px; border-radius: 6px;
@ -467,9 +476,9 @@
} }
.comments-toggle-button:hover { .comments-toggle-button:hover {
background: #0d6efd; filter: brightness(1.1);
transform: translateY(-2px); 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 { .comment-item {
@ -714,8 +723,8 @@
/* JSON Display Styles */ /* JSON Display Styles */
.json-button { .json-button {
background: #4caf50; background: var(--theme-color);
color: white; color: var(--white);
border: none; border: none;
padding: 4px 8px; padding: 4px 8px;
border-radius: 4px; border-radius: 4px;
@ -726,7 +735,7 @@
} }
.json-button:hover { .json-button:hover {
background: #45a049; filter: brightness(1.1);
transform: scale(1.05); transform: scale(1.05);
} }
@ -760,3 +769,107 @@
max-height: 400px; max-height: 400px;
overflow-y: auto; 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 [isPostingUserList, setIsPostingUserList] = useState(false);
const [userListRecords, setUserListRecords] = useState<any[]>([]); const [userListRecords, setUserListRecords] = useState<any[]>([]);
const [showJsonFor, setShowJsonFor] = useState<string | null>(null); const [showJsonFor, setShowJsonFor] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'comments' | 'ai-chat'>('comments');
const [aiChatHistory, setAiChatHistory] = useState<any[]>([]);
useEffect(() => { useEffect(() => {
// Setup Jetstream WebSocket for real-time comments (optional) // Setup Jetstream WebSocket for real-time comments (optional)
@ -151,6 +153,9 @@ function App() {
console.log('OAuth session found, loading all comments...'); console.log('OAuth session found, loading all comments...');
loadAllComments(); loadAllComments();
// Load AI chat history
loadAiChatHistory(userProfile.did);
// Load user list records if admin // Load user list records if admin
if (userProfile.did === appConfig.adminDid) { if (userProfile.did === appConfig.adminDid) {
loadUserListRecords(); loadUserListRecords();
@ -221,6 +226,50 @@ function App() {
return `https://via.placeholder.com/48x48/1185fe/ffffff?text=${initial}`; 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) => { const loadUserComments = async (did: string) => {
try { try {
console.log('Loading comments for DID:', did); console.log('Loading comments for DID:', did);
@ -305,7 +354,7 @@ function App() {
if (user.did && user.did.includes('-placeholder')) { if (user.did && user.did.includes('-placeholder')) {
console.log(`Resolving placeholder DID for ${user.handle}`); console.log(`Resolving placeholder DID for ${user.handle}`);
try { 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) { if (profileResponse.ok) {
const profileData = await profileResponse.json(); const profileData = await profileResponse.json();
if (profileData.did) { if (profileData.did) {
@ -456,7 +505,7 @@ function App() {
if (!record.value.author?.avatar && record.value.author?.handle) { if (!record.value.author?.avatar && record.value.author?.handle) {
try { try {
// Public API でプロフィール取得 // 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) { if (profileResponse.ok) {
const profileData = await profileResponse.json(); const profileData = await profileResponse.json();
@ -683,7 +732,7 @@ function App() {
try { try {
// Public APIでプロフィールを取得してDIDを解決 // 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) { if (profileResponse.ok) {
const profileData = await profileResponse.json(); const profileData = await profileResponse.json();
if (profileData.did) { if (profileData.did) {
@ -974,11 +1023,30 @@ function App() {
</div> </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 */} {/* Comments List */}
<div className="comments-list"> {activeTab === 'comments' && (
<div className="comments-header"> <div className="comments-list">
<h3>Comments</h3> <div className="comments-header">
</div> <h3>Comments</h3>
</div>
{comments.filter(shouldShowComment).length === 0 ? ( {comments.filter(shouldShowComment).length === 0 ? (
<p className="no-comments"> <p className="no-comments">
{appConfig.rkey ? `No comments for this post yet` : `No comments yet`} {appConfig.rkey ? `No comments for this post yet` : `No comments yet`}
@ -988,9 +1056,25 @@ function App() {
<div key={index} className="comment-item"> <div key={index} className="comment-item">
<div className="comment-header"> <div className="comment-header">
<img <img
src={record.value.author?.avatar || generatePlaceholderAvatar(record.value.author?.handle || 'unknown')} src={generatePlaceholderAvatar(record.value.author?.handle || 'unknown')}
alt="User Avatar" alt="User Avatar"
className="comment-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"> <div className="comment-author-info">
<span className="comment-author"> <span className="comment-author">
@ -1047,7 +1131,92 @@ function App() {
</div> </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 */} {/* Comment Form - Only show on post pages */}
{user && appConfig.rkey && ( {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', 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.', 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', 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 // Fetch AI profile on load
useEffect(() => { useEffect(() => {
const fetchAIProfile = async () => { const fetchAIProfile = async () => {
console.log('=== AI PROFILE FETCH START ===');
console.log('AI DID:', aiConfig.aiDid);
if (!aiConfig.aiDid) { if (!aiConfig.aiDid) {
console.log('No AI DID configured'); console.log('No AI DID configured');
return; return;
@ -42,51 +46,48 @@ export const AIChat: React.FC<AIChatProps> = ({ user, isEnabled }) => {
console.log('AI profile fetched successfully:', profile.data); console.log('AI profile fetched successfully:', profile.data);
const profileData = { const profileData = {
did: aiConfig.aiDid, did: aiConfig.aiDid,
handle: profile.data.handle || 'ai-assistant', handle: profile.data.handle,
displayName: profile.data.displayName || 'AI Assistant', displayName: profile.data.displayName,
avatar: profile.data.avatar || null, avatar: profile.data.avatar,
description: profile.data.description || null description: profile.data.description
}; };
console.log('Setting aiProfile to:', profileData);
setAiProfile(profileData); setAiProfile(profileData);
// Dispatch event to update Ask AI button // Dispatch event to update Ask AI button
window.dispatchEvent(new CustomEvent('aiProfileLoaded', { detail: profileData })); window.dispatchEvent(new CustomEvent('aiProfileLoaded', { detail: profileData }));
console.log('=== AI PROFILE FETCH SUCCESS (AGENT) ===');
return; return;
} }
// Fallback to public API // Fallback to public API
console.log('No agent available, trying public API for AI profile'); 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) { if (response.ok) {
const profileData = await response.json(); const profileData = await response.json();
console.log('AI profile fetched via public API:', profileData); console.log('AI profile fetched via public API:', profileData);
const profile = { const profile = {
did: aiConfig.aiDid, did: aiConfig.aiDid,
handle: profileData.handle || 'ai-assistant', handle: profileData.handle,
displayName: profileData.displayName || 'AI Assistant', displayName: profileData.displayName,
avatar: profileData.avatar || null, avatar: profileData.avatar,
description: profileData.description || null description: profileData.description
}; };
console.log('Setting aiProfile to:', profile);
setAiProfile(profile); setAiProfile(profile);
// Dispatch event to update Ask AI button // Dispatch event to update Ask AI button
window.dispatchEvent(new CustomEvent('aiProfileLoaded', { detail: profile })); window.dispatchEvent(new CustomEvent('aiProfileLoaded', { detail: profile }));
console.log('=== AI PROFILE FETCH SUCCESS (PUBLIC API) ===');
return; return;
} else {
console.error('Public API failed with status:', response.status);
} }
} catch (error) { } catch (error) {
console.log('Failed to fetch AI profile, using defaults:', error); console.error('Failed to fetch AI profile:', error);
const fallbackProfile = { setAiProfile(null);
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.log('=== AI PROFILE FETCH FAILED ===');
}; };
fetchAIProfile(); fetchAIProfile();
@ -97,9 +98,11 @@ export const AIChat: React.FC<AIChatProps> = ({ user, isEnabled }) => {
// Listen for AI question posts from base.html // Listen for AI question posts from base.html
const handleAIQuestion = async (event: any) => { 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('AIChat received question:', event.detail.question);
console.log('Current aiProfile state:', aiProfile);
setIsProcessing(true); setIsProcessing(true);
try { try {
await postQuestionAndGenerateResponse(event.detail.question); await postQuestionAndGenerateResponse(event.detail.question);
@ -120,10 +123,10 @@ export const AIChat: React.FC<AIChatProps> = ({ user, isEnabled }) => {
return () => { return () => {
window.removeEventListener('postAIQuestion', handleAIQuestion); window.removeEventListener('postAIQuestion', handleAIQuestion);
}; };
}, [user, isEnabled, isProcessing]); }, [user, isEnabled, isProcessing, aiProfile]);
const postQuestionAndGenerateResponse = async (question: string) => { const postQuestionAndGenerateResponse = async (question: string) => {
if (!user || !aiConfig.askAi) return; if (!user || !aiConfig.askAi || !aiProfile) return;
setIsLoading(true); setIsLoading(true);
@ -232,6 +235,9 @@ Answer:`;
// 5. Save AI response in background // 5. Save AI response in background
const answerRkey = now.toISOString().replace(/[:.]/g, '-') + '-answer'; const answerRkey = now.toISOString().replace(/[:.]/g, '-') + '-answer';
console.log('=== SAVING AI ANSWER ===');
console.log('Current aiProfile:', aiProfile);
const answerRecord = { const answerRecord = {
$type: appConfig.collections.chat, $type: appConfig.collections.chat,
answer: aiAnswer, answer: aiAnswer,
@ -239,12 +245,15 @@ Answer:`;
url: window.location.href, url: window.location.href,
createdAt: now.toISOString(), createdAt: now.toISOString(),
author: { author: {
did: aiConfig.aiDid, did: aiProfile.did,
handle: 'AI Assistant', handle: aiProfile.handle,
displayName: 'AI Assistant', displayName: aiProfile.displayName,
avatar: aiProfile.avatar,
}, },
}; };
console.log('Answer record to save:', answerRecord);
// Save to ATProto asynchronously (don't wait for it) // Save to ATProto asynchronously (don't wait for it)
agent.api.com.atproto.repo.putRecord({ agent.api.com.atproto.repo.putRecord({
repo: user.did, repo: user.did,

View File

@ -13,6 +13,7 @@ export interface AppConfig {
aiProvider: string; aiProvider: string;
aiModel: string; aiModel: string;
aiHost: string; aiHost: string;
bskyPublicApi: string;
} }
// Generate collection names from host // Generate collection names from host
@ -80,13 +81,15 @@ export function getAppConfig(): AppConfig {
const aiProvider = import.meta.env.VITE_AI_PROVIDER || 'ollama'; const aiProvider = import.meta.env.VITE_AI_PROVIDER || 'ollama';
const aiModel = import.meta.env.VITE_AI_MODEL || 'gemma2:2b'; const aiModel = import.meta.env.VITE_AI_MODEL || 'gemma2:2b';
const aiHost = import.meta.env.VITE_AI_HOST || 'https://ollama.syui.ai'; 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:', { console.log('App configuration:', {
host, host,
adminDid, adminDid,
collections, collections,
rkey: rkey || 'none (not on post page)', 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 { return {
@ -98,7 +101,8 @@ export function getAppConfig(): AppConfig {
aiAskAi, aiAskAi,
aiProvider, aiProvider,
aiModel, aiModel,
aiHost aiHost,
bskyPublicApi
}; };
} }

96
src/ollama_proxy.rs Normal file
View File

@ -0,0 +1,96 @@
use actix_web::{web, App, HttpResponse, HttpServer, middleware};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Mutex;
use chrono::{DateTime, Utc};
#[derive(Clone)]
struct RateLimiter {
requests: Arc<Mutex<HashMap<String, Vec<DateTime<Utc>>>>>,
limit_per_hour: usize,
}
impl RateLimiter {
fn new(limit: usize) -> Self {
Self {
requests: Arc::new(Mutex::new(HashMap::new())),
limit_per_hour: limit,
}
}
fn check_limit(&self, user_id: &str) -> bool {
let mut requests = self.requests.lock().unwrap();
let now = Utc::now();
let hour_ago = now - chrono::Duration::hours(1);
let user_requests = requests.entry(user_id.to_string()).or_insert(Vec::new());
user_requests.retain(|&time| time > hour_ago);
if user_requests.len() < self.limit_per_hour {
user_requests.push(now);
true
} else {
false
}
}
}
#[derive(Deserialize)]
struct GenerateRequest {
model: String,
prompt: String,
stream: bool,
options: Option<serde_json::Value>,
}
async fn proxy_generate(
req: web::Json<GenerateRequest>,
data: web::Data<AppState>,
user_info: web::ReqData<UserInfo>, // ATProto認証から取得
) -> Result<HttpResponse, actix_web::Error> {
// レート制限チェック
if !data.rate_limiter.check_limit(&user_info.did) {
return Ok(HttpResponse::TooManyRequests()
.json(serde_json::json!({
"error": "Rate limit exceeded. Please try again later."
})));
}
// プロンプトサイズ制限
if req.prompt.len() > 500 {
return Ok(HttpResponse::BadRequest()
.json(serde_json::json!({
"error": "Prompt too long. Maximum 500 characters."
})));
}
// Ollamaへのリクエスト転送
let client = reqwest::Client::new();
let response = client
.post("http://localhost:11434/api/generate")
.json(&req.into_inner())
.send()
.await?;
let body = response.bytes().await?;
Ok(HttpResponse::Ok()
.content_type("application/json")
.body(body))
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
let rate_limiter = RateLimiter::new(20); // 1時間に20リクエスト
HttpServer::new(move || {
App::new()
.app_data(web::Data::new(AppState {
rate_limiter: rate_limiter.clone(),
}))
.wrap(middleware::Logger::default())
.route("/api/generate", web::post().to(proxy_generate))
})
.bind("127.0.0.1:8080")?
.run()
.await
}