4 Commits

Author SHA1 Message Date
5d97576544 v0.1.6: Major improvements to OAuth display and stream configuration
- Fix AI Chat History display layout and content formatting
- Unify comment layout structure across all comment types
- Remove hardcoded values from stream.rs, add config.toml support
- Optimize AI comment generation with character limits
- Improve translation length limits (3000 characters)
- Add comprehensive AI configuration management

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-15 20:44:02 +09:00
d16b88a499 test update json 2025-06-15 20:41:02 +09:00
4df7f72312 update binary 2025-06-15 15:46:34 +09:00
af28cefba0 add index.json 2025-06-15 15:31:53 +09:00
33 changed files with 726 additions and 224 deletions

5
.gitignore vendored
View File

@@ -5,6 +5,7 @@
*.swo
*~
.DS_Store
cloudflare-config.yml
my-blog/public/
dist
node_modules
@@ -12,7 +13,3 @@ package-lock.json
my-blog/static/assets/comment-atproto-*
bin/ailog
docs
my-blog/static/index.html
my-blog/templates/oauth-assets.html
cloudflared-config.yml
.config

View File

@@ -1,6 +1,6 @@
[package]
name = "ailog"
version = "0.1.7"
version = "0.1.6"
edition = "2021"
authors = ["syui"]
description = "A static blog generator with AI features"

32
Dockerfile Normal file
View File

@@ -0,0 +1,32 @@
# Multi-stage build for ailog
FROM rust:1.75 as builder
WORKDIR /usr/src/app
COPY Cargo.toml Cargo.lock ./
COPY src ./src
RUN cargo build --release
FROM debian:bookworm-slim
# Install runtime dependencies
RUN apt-get update && apt-get install -y \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy the binary
COPY --from=builder /usr/src/app/target/release/ailog /usr/local/bin/ailog
# Copy blog content
COPY my-blog ./blog
# Build static site
RUN ailog build blog
# Expose port
EXPOSE 8080
# Run server
CMD ["ailog", "serve", "blog"]

128
action.yml Normal file
View File

@@ -0,0 +1,128 @@
name: 'ailog Static Site Generator'
description: 'AI-powered static blog generator with atproto integration'
author: 'syui'
branding:
icon: 'book-open'
color: 'orange'
inputs:
content-dir:
description: 'Content directory containing markdown files'
required: false
default: 'content'
output-dir:
description: 'Output directory for generated site'
required: false
default: 'public'
template-dir:
description: 'Template directory'
required: false
default: 'templates'
static-dir:
description: 'Static assets directory'
required: false
default: 'static'
config-file:
description: 'Configuration file path'
required: false
default: 'ailog.toml'
ai-integration:
description: 'Enable AI features'
required: false
default: 'true'
atproto-integration:
description: 'Enable atproto/OAuth features'
required: false
default: 'true'
outputs:
site-url:
description: 'Generated site URL'
value: ${{ steps.generate.outputs.site-url }}
build-time:
description: 'Build time in seconds'
value: ${{ steps.generate.outputs.build-time }}
runs:
using: 'composite'
steps:
- name: Cache ailog binary
uses: actions/cache@v4
with:
path: ./bin
key: ailog-bin-${{ runner.os }}
restore-keys: |
ailog-bin-${{ runner.os }}
- name: Setup ailog binary
shell: bash
run: |
# Check if pre-built binary exists
if [ -f "./bin/ailog-linux-x86_64" ]; then
echo "Using pre-built binary from repository"
chmod +x ./bin/ailog-linux-x86_64
CURRENT_VERSION=$(./bin/ailog-linux-x86_64 --version 2>/dev/null || echo "unknown")
echo "Binary version: $CURRENT_VERSION"
else
echo "No pre-built binary found, trying to build from source..."
if command -v cargo >/dev/null 2>&1; then
cargo build --release
mkdir -p ./bin
cp ./target/release/ailog ./bin/ailog-linux-x86_64
echo "Built from source: $(./bin/ailog-linux-x86_64 --version 2>/dev/null)"
else
echo "Error: No binary found and cargo not available"
exit 1
fi
fi
- name: Setup Node.js for OAuth app
if: ${{ inputs.atproto-integration == 'true' }}
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Build OAuth app
if: ${{ inputs.atproto-integration == 'true' }}
shell: bash
run: |
if [ -d "oauth" ]; then
cd oauth
npm install
npm run build
cp -r dist/* ../${{ inputs.static-dir }}/
fi
- name: Generate site
id: generate
shell: bash
run: |
start_time=$(date +%s)
# Change to blog directory and run build
# Note: ailog build only takes a path argument, not options
if [ -d "my-blog" ]; then
cd my-blog
../bin/ailog-linux-x86_64 build
else
# If no my-blog directory, use current directory
./bin/ailog-linux-x86_64 build .
fi
end_time=$(date +%s)
build_time=$((end_time - start_time))
echo "build-time=${build_time}" >> $GITHUB_OUTPUT
echo "site-url=file://$(pwd)/${{ inputs.output-dir }}" >> $GITHUB_OUTPUT
- name: Display build summary
shell: bash
run: |
echo "✅ ailog build completed successfully"
echo "📁 Output directory: ${{ inputs.output-dir }}"
echo "⏱️ Build time: ${{ steps.generate.outputs.build-time }}s"
if [ -d "${{ inputs.output-dir }}" ]; then
echo "📄 Generated files:"
find ${{ inputs.output-dir }} -type f | head -10
fi

1
ai_prompt.txt Normal file
View File

@@ -0,0 +1 @@
あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。

Binary file not shown.

View File

@@ -1,11 +1,10 @@
#!/bin/zsh
set -e
cb=ai.syui.log
cl=( $cb.chat $cb.chat.comment $cb.chat.lang )
f=~/.config/syui/ai/bot/token.json
f=~/.config/syui/ai/bot/token.json
default_collection="ai.syui.log.chat.comment"
default_pds="bsky.social"
default_did=`cat $f|jq -r .did`
@@ -19,6 +18,7 @@ did=${3:-$default_did}
token=${4:-$default_token}
req=com.atproto.repo.deleteRecord
url=https://$pds/xrpc/$req
for i in $cl; do
echo $i
rkeys=($(curl -sL "https://$default_pds/xrpc/com.atproto.repo.listRecords?repo=$did&collection=$i&limit=100"|jq -r ".records[]?.uri"|cut -d '/' -f 5))

18
cloudflared-config.yml Normal file
View File

@@ -0,0 +1,18 @@
tunnel: ec5a422d-7678-4e73-bf38-6105ffd4766a
credentials-file: /Users/syui/.cloudflared/ec5a422d-7678-4e73-bf38-6105ffd4766a.json
ingress:
- hostname: log.syui.ai
service: http://localhost:4173
originRequest:
noHappyEyeballs: true
- hostname: ollama.syui.ai
service: http://localhost:11434
originRequest:
noHappyEyeballs: true
httpHostHeader: "localhost:11434"
# Cloudflare Accessを無効化する場合は以下をコメントアウト
# accessPolicy: bypass
- service: http_status:404

View File

@@ -20,7 +20,6 @@ model = "gemma3:4b"
host = "https://ollama.syui.ai"
system_prompt = "あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"
ai_did = "did:plc:4hqjfn7m6n5hno3doamuhgef"
#num_predict = 200
[oauth]
json = "client-metadata.json"

View File

@@ -57,28 +57,24 @@ $ npm run build
$ npm run preview
```
```sh:ouath/.env.production
```sh
# Production environment variables
VITE_APP_HOST=https://syui.ai
VITE_OAUTH_CLIENT_ID=https://syui.ai/client-metadata.json
VITE_OAUTH_REDIRECT_URI=https://syui.ai/oauth/callback
VITE_APP_HOST=https://example.com
VITE_OAUTH_CLIENT_ID=https://example.com/client-metadata.json
VITE_OAUTH_REDIRECT_URI=https://example.com/oauth/callback
VITE_ADMIN_DID=did:plc:uqzpqmrjnptsxezjx4xuh2mn
# Base collection (all others are derived via getCollectionNames)
VITE_OAUTH_COLLECTION=ai.syui.log
# Collection names for OAuth app
VITE_COLLECTION_COMMENT=ai.syui.log
VITE_COLLECTION_USER=ai.syui.log.user
VITE_COLLECTION_CHAT=ai.syui.log.chat
# AI Configuration
VITE_AI_ENABLED=true
VITE_AI_ASK_AI=true
VITE_AI_PROVIDER=ollama
VITE_AI_MODEL=gemma3:4b
VITE_AI_HOST=https://ollama.syui.ai
VITE_AI_SYSTEM_PROMPT="ai"
VITE_AI_DID=did:plc:4hqjfn7m6n5hno3doamuhgef
# Collection names for ailog (backward compatibility)
AILOG_COLLECTION_COMMENT=ai.syui.log
AILOG_COLLECTION_USER=ai.syui.log.user
# API Configuration
VITE_BSKY_PUBLIC_API=https://public.api.bsky.app
VITE_ATPROTO_API=https://bsky.social
```
これは`ailog oauth build my-blog``./my-blog/config.toml`から`./oauth/.env.production`が生成されます。
@@ -119,8 +115,15 @@ $ cloudflared tunnel --config cloudflared-config.yml run
$ cloudflared tunnel route dns ${uuid} example.com
```
以下の2つのcollection recordを生成します。ユーザーには`ai.syui.log`が生成され、ここにコメントが記録されます。それを取得して表示しています。`ai.syui.log.user`は管理者である`VITE_ADMIN_DID`用です。
```sh
$ ailog auth init
VITE_COLLECTION_COMMENT=ai.syui.log
VITE_COLLECTION_USER=ai.syui.log.user
```
```sh
$ ailog auth login
$ ailog stream server
```
@@ -132,9 +135,8 @@ $ ailog stream server
`ask-AI`の仕組みは割愛します。後に変更される可能性が高いと思います。
`llm`, `mcp`, `atproto`などの組み合わせです。
local llm, mcp, atproto組み合わせです。
現在、`/index.json`を監視して、更新があれば、翻訳などを行い自動ポストする機能があります。
## code syntax

View File

@@ -248,7 +248,7 @@ a.view-markdown:any-link {
}
.post-title a {
color: var(--theme-color);
color: #1f2328;
text-decoration: none;
font-size: 18px;
font-weight: 600;
@@ -822,13 +822,6 @@ article.article-content {
}
.comment-section {
max-width: 100% !important;
padding: 0px !important;
margin: 0px !important;
}
.comment-container {
max-width: 100% !important;
padding: 0px !important;
margin: 0px !important;
}

View File

@@ -0,0 +1,3 @@
<!-- OAuth Comment System - Load globally for session management -->
<script type="module" crossorigin src="/assets/comment-atproto-C3utAhPv.js"></script>
<link rel="stylesheet" crossorigin href="/assets/comment-atproto-BH-72ESb.css">

View File

@@ -253,20 +253,6 @@ function setupAskAIEventListeners() {
handleAIResponse(event.detail);
});
// Track IME composition state
let isComposing = false;
const aiQuestionInput = document.getElementById('aiQuestion');
if (aiQuestionInput) {
aiQuestionInput.addEventListener('compositionstart', function() {
isComposing = true;
});
aiQuestionInput.addEventListener('compositionend', function() {
isComposing = false;
});
}
// Keyboard shortcuts
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
@@ -276,8 +262,8 @@ function setupAskAIEventListeners() {
}
}
// Enter key to send message (only when not composing Japanese input)
if (e.key === 'Enter' && e.target.id === 'aiQuestion' && !e.shiftKey && !isComposing) {
// Enter key to send message
if (e.key === 'Enter' && e.target.id === 'aiQuestion' && !e.shiftKey) {
e.preventDefault();
askQuestion();
}

View File

@@ -20,6 +20,19 @@
<a href="{{ post.url }}">{{ post.title }}</a>
</h3>
{% if post.excerpt %}
<p class="post-excerpt">{{ post.excerpt }}</p>
{% endif %}
<div class="post-actions">
<a href="{{ post.url }}" class="read-more">Read more</a>
{% if post.markdown_url %}
<a href="{{ post.markdown_url }}" class="view-markdown" title="View Markdown">.md</a>
{% endif %}
{% if post.translation_url %}
<a href="{{ post.translation_url }}" class="view-translation" title="View Translation">🌐</a>
{% endif %}
</div>
</div>
</article>
{% endfor %}

View File

@@ -0,0 +1,3 @@
<!-- OAuth Comment System - Load globally for session management -->
<script type="module" crossorigin src="/assets/comment-atproto-C3utAhPv.js"></script>
<link rel="stylesheet" crossorigin href="/assets/comment-atproto-BH-72ESb.css">

View File

@@ -168,14 +168,7 @@
}
@media (max-width: 1000px) {
* {
max-width: 100% !important;
box-sizing: border-box !important;
}
.app .app-main {
max-width: 100% !important;
margin: 0 !important;
padding: 0px !important;
}
@@ -193,8 +186,8 @@
}
.comment-section {
padding: 30px 0 !important;
margin: 0px !important;
padding: 0px !important;
margin: 0px !important;
}
.comment-content {
@@ -216,7 +209,6 @@
/* Ensure full width on mobile */
.app {
max-width: 100vw !important;
overflow-x: hidden !important;
}
/* Fix button overflow */
@@ -332,14 +324,6 @@
/* padding: 20px; - removed to avoid double padding */
}
@media (max-width: 768px) {
.comment-section {
max-width: 100%;
margin: 0;
padding: 0;
}
}
.auth-section {
background: #f8f9fa;
@@ -350,38 +334,6 @@
text-align: center;
}
.auth-section.search-bar-layout {
display: flex;
align-items: center;
padding: 10px;
gap: 10px;
}
.auth-section.search-bar-layout .handle-input {
flex: 1;
margin: 0;
padding: 10px 15px;
font-size: 16px;
border: 1px solid #dee2e6;
border-radius: 6px 0 0 6px;
background: white;
outline: none;
transition: border-color 0.2s;
}
.auth-section.search-bar-layout .handle-input:focus {
border-color: var(--theme-color);
}
.auth-section.search-bar-layout .atproto-button {
margin: 0;
padding: 10px 20px;
border-radius: 0 6px 6px 0;
min-width: 50px;
font-weight: bold;
height: auto;
}
.atproto-button {
background: var(--theme-color);
color: var(--white);
@@ -415,30 +367,6 @@
text-align: center;
}
/* Override for search bar layout */
.search-bar-layout .handle-input {
width: auto;
text-align: left;
}
/* Mobile responsive for search bar */
@media (max-width: 480px) {
.auth-section.search-bar-layout {
flex-direction: column;
gap: 8px;
}
.auth-section.search-bar-layout .handle-input {
width: 100%;
border-radius: 6px;
}
.auth-section.search-bar-layout .atproto-button {
width: 100%;
border-radius: 6px;
}
}
.auth-hint {
color: #6c757d;
font-size: 14px;
@@ -571,8 +499,9 @@
}
.comments-list {
border: 1px solid #ddd;
border-radius: 8px;
padding: 0px;
padding: 20px;
}
.comments-header {
@@ -931,6 +860,28 @@
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;
@@ -981,8 +932,4 @@
padding: 40px 20px;
color: #656d76;
font-style: italic;
}
.chat-message.comment-style {
border-left: 4px solid var(--theme-color);
}
}

View File

@@ -28,7 +28,6 @@ function App() {
const [aiChatHistory, setAiChatHistory] = useState<any[]>([]);
const [langEnRecords, setLangEnRecords] = useState<any[]>([]);
const [aiCommentRecords, setAiCommentRecords] = useState<any[]>([]);
const [aiProfile, setAiProfile] = useState<any>(null);
useEffect(() => {
// Setup Jetstream WebSocket for real-time comments (optional)
@@ -89,20 +88,6 @@ function App() {
// Load AI chat history (認証状態に関係なく、全ユーザーのチャット履歴を表示)
loadAiChatHistory();
// Load AI profile
const fetchAiProfile = async () => {
try {
const response = await fetch(`${appConfig.bskyPublicApi}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(appConfig.aiDid)}`);
if (response.ok) {
const data = await response.json();
setAiProfile(data);
}
} catch (err) {
// Use default values if fetch fails
}
};
fetchAiProfile();
// Handle popstate events for mock OAuth flow
const handlePopState = () => {
@@ -274,8 +259,8 @@ function App() {
if (appConfig.rkey) {
// On post page: show only chats for this specific post
filteredRecords = allChatRecords.filter(record => {
const recordRkey = record.value.post?.url ? new URL(record.value.post.url).pathname.split('/').pop()?.replace(/\.html$/, '') : '';
return recordRkey === appConfig.rkey;
const recordPath = record.value.post?.url ? new URL(record.value.post.url).pathname : '';
return recordPath === window.location.pathname;
});
} else {
// On top page: show latest 3 records from all pages
@@ -317,12 +302,13 @@ function App() {
const langData = await langResponse.json();
const langRecords = langData.records || [];
// Filter by current page rkey if on post page
// Filter by current page path if on post page
const filteredLangRecords = appConfig.rkey
? langRecords.filter(record => {
// Compare rkey only (last part of path)
const recordRkey = record.value.post?.url ? new URL(record.value.post.url).pathname.split('/').pop()?.replace(/\.html$/, '') : '';
return recordRkey === appConfig.rkey;
// Compare path only, not full URL to support localhost vs production
const recordPath = record.value.post?.url ? new URL(record.value.post.url).pathname :
record.value.url ? new URL(record.value.url).pathname : '';
return recordPath === window.location.pathname;
})
: langRecords.slice(0, 3); // Top page: latest 3
@@ -335,12 +321,13 @@ function App() {
const commentData = await commentResponse.json();
const commentRecords = commentData.records || [];
// Filter by current page rkey if on post page
// Filter by current page path if on post page
const filteredCommentRecords = appConfig.rkey
? commentRecords.filter(record => {
// Compare rkey only (last part of path)
const recordRkey = record.value.post?.url ? new URL(record.value.post.url).pathname.split('/').pop()?.replace(/\.html$/, '') : '';
return recordRkey === appConfig.rkey;
// Compare path only, not full URL to support localhost vs production
const recordPath = record.value.post?.url ? new URL(record.value.post.url).pathname :
record.value.url ? new URL(record.value.url).pathname : '';
return recordPath === window.location.pathname;
})
: commentRecords.slice(0, 3); // Top page: latest 3
@@ -553,14 +540,16 @@ function App() {
// ページpathでフィルタリング指定された場合
const filteredComments = pageUrl && appConfig.rkey
const filteredComments = pageUrl
? userComments.filter(record => {
try {
// Compare rkey only (last part of path)
const recordRkey = record.value.url ? new URL(record.value.url).pathname.split('/').pop() : '';
return recordRkey === appConfig.rkey;
// Compare path only, not full URL to support localhost vs production
const recordPath = record.value.url ? new URL(record.value.url).pathname : '';
const currentPath = new URL(pageUrl).pathname;
return recordPath === currentPath;
} catch (err) {
return false;
// Fallback to exact match if URL parsing fails
return record.value.url === pageUrl;
}
})
: userComments;
@@ -981,7 +970,7 @@ function App() {
{authorInfo?.displayName || 'AI'}
</span>
<span className="comment-handle">
@{authorInfo?.handle || aiProfile?.handle || 'yui.syui.ai'}
@{authorInfo?.handle || 'ai'}
</span>
</div>
<span className="comment-date">
@@ -1054,28 +1043,28 @@ function App() {
<section className="comment-section">
{/* Authentication Section */}
{!user ? (
<div className="auth-section search-bar-layout">
<input
type="text"
id="handle-input"
name="handle"
placeholder="user.bsky.social"
className="handle-input"
value={handleInput}
onChange={(e) => setHandleInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
executeOAuth();
}
}}
/>
<div className="auth-section">
<button
onClick={executeOAuth}
className="atproto-button"
>
<i class="fab fa-bluesky"></i>
atproto
</button>
<div className="username-input-section">
<input
type="text"
placeholder="user.bsky.social"
className="handle-input"
value={handleInput}
onChange={(e) => setHandleInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
executeOAuth();
}
}}
/>
</div>
</div>
) : (
<div className="user-section">
@@ -1105,8 +1094,6 @@ function App() {
{/* User List Form */}
<div className="user-list-form">
<textarea
id="user-list-input"
name="userList"
value={userListInput}
onChange={(e) => setUserListInput(e.target.value)}
placeholder="ユーザーハンドルをカンマ区切りで入力&#10;例: syui.ai, yui.syui.ai, user.bsky.social"
@@ -1195,25 +1182,25 @@ function App() {
className={`tab-button ${activeTab === 'comments' ? 'active' : ''}`}
onClick={() => setActiveTab('comments')}
>
comment ({comments.filter(shouldShowComment).length})
Comments ({comments.filter(shouldShowComment).length})
</button>
<button
className={`tab-button ${activeTab === 'ai-chat' ? 'active' : ''}`}
onClick={() => setActiveTab('ai-chat')}
>
chat ({aiChatHistory.length})
AI Chat History ({aiChatHistory.length})
</button>
<button
className={`tab-button ${activeTab === 'lang-en' ? 'active' : ''}`}
onClick={() => setActiveTab('lang-en')}
>
en ({langEnRecords.length})
Lang: EN ({langEnRecords.length})
</button>
<button
className={`tab-button ${activeTab === 'ai-comment' ? 'active' : ''}`}
onClick={() => setActiveTab('ai-comment')}
>
feedback ({aiCommentRecords.length})
AI Comment ({aiCommentRecords.length})
</button>
</div>
@@ -1317,7 +1304,10 @@ function App() {
{/* AI Chat History List */}
{activeTab === 'ai-chat' && (
<div className="comments-list">
<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>
) : (
@@ -1325,12 +1315,12 @@ function App() {
// For AI responses, use AI DID; for user questions, use the actual author
const isAiResponse = record.value.type === 'answer';
const displayDid = isAiResponse ? appConfig.aiDid : record.value.author?.did;
const displayHandle = isAiResponse ? (aiProfile?.handle || 'yui.syui.ai') : record.value.author?.handle;
const displayHandle = isAiResponse ? 'ai.syui' : record.value.author?.handle;
const displayName = isAiResponse ? 'AI' : (record.value.author?.displayName || record.value.author?.handle);
return (
<div key={index} className="comment-item">
<div className="comment-header">
<div key={index} className="chat-item">
<div className="chat-header">
<img
src={generatePlaceholderAvatar(displayHandle || 'unknown')}
alt={isAiResponse ? "AI Avatar" : "User Avatar"}
@@ -1414,7 +1404,7 @@ function App() {
{/* Lang: EN List */}
{activeTab === 'lang-en' && (
<div className="comments-list">
<div className="lang-en-list">
{langEnRecords.length === 0 ? (
<p className="no-content">No English translations yet</p>
) : (
@@ -1459,7 +1449,7 @@ function App() {
AI
</span>
<span className="comment-handle">
@{aiProfile?.handle || 'yui.syui.ai'}
@ai
</span>
</div>
<span className="comment-date">
@@ -1506,12 +1496,11 @@ function App() {
</div>
)}
{/* Comment Form - Only show on post pages when Comments tab is active */}
{user && appConfig.rkey && activeTab === 'comments' && (
{/* Comment Form - Only show on post pages */}
{user && appConfig.rkey && (
<div className="comment-form">
<h3>Post a Comment</h3>
<textarea
id="comment-text"
name="commentText"
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
placeholder="Write your comment..."
@@ -1532,6 +1521,13 @@ function App() {
</div>
)}
{/* Show authentication status on non-post pages */}
{user && !appConfig.rkey && (
<div className="auth-status">
<p> Authenticated as @{user.handle}</p>
<p><small>Visit a post page to comment</small></p>
</div>
)}
</section>
</main>
@@ -1541,4 +1537,4 @@ function App() {
);
}
export default App;
export default App;

View File

@@ -14,7 +14,7 @@ const response = await fetch(`${aiConfig.host}/api/generate`, {
options: {
temperature: 0.9,
top_p: 0.9,
num_predict: 200,
num_predict: 80,
repeat_penalty: 1.1,
}
}),

View File

@@ -199,7 +199,7 @@ Answer:`;
options: {
temperature: 0.9,
top_p: 0.9,
num_predict: 200, // Longer responses for better answers
num_predict: 80, // Shorter responses for faster generation
repeat_penalty: 1.1,
}
}),

View File

@@ -62,15 +62,11 @@ function generateBaseCollectionFromHost(host: string): string {
}
// Extract rkey from current URL
// /posts/xxx -> xxx (remove .html if present)
// /posts/xxx -> xxx
function extractRkeyFromUrl(): string | undefined {
const pathname = window.location.pathname;
const match = pathname.match(/\/posts\/([^/]+)\/?$/);
if (match) {
// Remove .html extension if present
return match[1].replace(/\.html$/, '');
}
return undefined;
return match ? match[1] : undefined;
}
// Get application configuration from environment variables

View File

@@ -22,7 +22,6 @@ struct AiConfig {
model: String,
system_prompt: String,
bsky_api: String,
num_predict: Option<i32>,
}
impl Default for AiConfig {
@@ -34,7 +33,6 @@ impl Default for AiConfig {
model: "gemma3:4b".to_string(),
system_prompt: "あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。相手のことが大好きで、ときどき甘えたり、照れたりします。でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。".to_string(),
bsky_api: "https://public.api.bsky.app".to_string(),
num_predict: None,
}
}
}
@@ -195,11 +193,6 @@ fn load_ai_config_from_project() -> Result<AiConfig> {
.and_then(|v| v.as_str())
.unwrap_or("あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。相手のことが大好きで、ときどき甘えたり、照れたりします。でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。")
.to_string();
let num_predict = ai_config
.and_then(|ai| ai.get("num_predict"))
.and_then(|v| v.as_integer())
.map(|v| v as i32);
// Extract OAuth config for bsky_api
let oauth_config = config.get("oauth").and_then(|v| v.as_table());
@@ -216,7 +209,6 @@ fn load_ai_config_from_project() -> Result<AiConfig> {
model,
system_prompt,
bsky_api,
num_predict,
})
}
@@ -1058,20 +1050,18 @@ async fn generate_ai_content(content: &str, prompt_type: &str, ai_config: &AiCon
};
format!(
"{}\n\n# 指示\nこのブログ記事を読んで、アイらしい感想をください。\n- 100文字以内の感想\n- 技術的な内容への素朴な驚きや発見\n- アイらしい感嘆詞で始める\n- 簡潔で分かりやすく\n\n# ブログ記事(要約)\n{}\n\n# 出力形式\n感想のみ(説明や詳細は不要):",
"{}\n\n# 指示\nこのブログ記事を読んで、アイらしい感想を一言でください。\n- 30文字以内の短い感想\n- 技術的な内容への素朴な驚きや発見\n- 「わー!」「すごい!」など、アイらしい感嘆詞で始める\n- 簡潔で分かりやすく\n\n# ブログ記事(要約)\n{}\n\n# 出力形式\n一言の感想のみ(説明や詳細は不要):",
system_prompt, limited_content
)
},
_ => return Err(anyhow::anyhow!("Unknown prompt type: {}", prompt_type)),
};
let num_predict = ai_config.num_predict.unwrap_or_else(|| {
match prompt_type {
"comment" => 150, // Longer for comments (about 100 characters)
"translate" => 3000, // Much longer for translations
_ => 300,
}
});
let num_predict = match prompt_type {
"comment" => 50, // Very short for comments (about 30-40 characters)
"translate" => 3000, // Much longer for translations
_ => 300,
};
let request = OllamaRequest {
model: model.to_string(),
@@ -1450,4 +1440,4 @@ async fn store_atproto_record(
}
Ok(())
}
}

View File

@@ -41,7 +41,6 @@ pub struct AiConfig {
pub api_key: Option<String>,
pub gpt_endpoint: Option<String>,
pub atproto_config: Option<AtprotoConfig>,
pub num_predict: Option<i32>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
@@ -164,7 +163,6 @@ impl Default for Config {
api_key: None,
gpt_endpoint: None,
atproto_config: None,
num_predict: None,
}),
}
}

View File

@@ -8,7 +8,7 @@ Type=simple
User=syui
Group=syui
WorkingDirectory=/home/syui/git/log
ExecStart=/home/syui/.cargo/bin/ailog stream start my-blog --ai-generate
ExecStart=/home/syui/.cargo/bin/ailog stream start my-blog
Restart=always
RestartSec=5
StandardOutput=journal
@@ -19,4 +19,4 @@ Environment=RUST_LOG=info
Environment=AILOG_DEBUG_ALL=1
[Install]
WantedBy=multi-user.target
WantedBy=multi-user.target

103
templates/api.md Normal file
View File

@@ -0,0 +1,103 @@
# API Documentation
## Public Functions
{{#each api.public_functions}}
### `{{this.name}}`
{{#if this.docs}}
{{this.docs}}
{{/if}}
**Visibility:** `{{this.visibility}}`
{{#if this.is_async}}**Async:** Yes{{/if}}
{{#if this.parameters}}
**Parameters:**
{{#each this.parameters}}
- `{{this.name}}`: `{{this.param_type}}`{{#if this.is_mutable}} (mutable){{/if}}
{{/each}}
{{/if}}
{{#if this.return_type}}
**Returns:** `{{this.return_type}}`
{{/if}}
---
{{/each}}
## Public Structs
{{#each api.public_structs}}
### `{{this.name}}`
{{#if this.docs}}
{{this.docs}}
{{/if}}
**Visibility:** `{{this.visibility}}`
{{#if this.fields}}
**Fields:**
{{#each this.fields}}
- `{{this.name}}`: `{{this.field_type}}` ({{this.visibility}})
{{#if this.docs}} - {{this.docs}}{{/if}}
{{/each}}
{{/if}}
---
{{/each}}
## Public Enums
{{#each api.public_enums}}
### `{{this.name}}`
{{#if this.docs}}
{{this.docs}}
{{/if}}
**Visibility:** `{{this.visibility}}`
{{#if this.variants}}
**Variants:**
{{#each this.variants}}
- `{{this.name}}`
{{#if this.docs}} - {{this.docs}}{{/if}}
{{#if this.fields}}
**Fields:**
{{#each this.fields}}
- `{{this.name}}`: `{{this.field_type}}`
{{/each}}
{{/if}}
{{/each}}
{{/if}}
---
{{/each}}
## Public Traits
{{#each api.public_traits}}
### `{{this.name}}`
{{#if this.docs}}
{{this.docs}}
{{/if}}
**Visibility:** `{{this.visibility}}`
{{#if this.methods}}
**Methods:**
{{#each this.methods}}
- `{{this.name}}({{#each this.parameters}}{{this.name}}: {{this.param_type}}{{#unless @last}}, {{/unless}}{{/each}}){{#if this.return_type}} -> {{this.return_type}}{{/if}}`
{{#if this.docs}} - {{this.docs}}{{/if}}
{{/each}}
{{/if}}
---
{{/each}}

19
templates/changelog.md Normal file
View File

@@ -0,0 +1,19 @@
# Changelog
## Recent Changes
{{#each commits}}
### {{this.date}}
**{{this.hash}}** by {{this.author}}
{{this.message}}
---
{{/each}}
## Summary
- **Total Commits:** {{commits.length}}
- **Contributors:** {{#unique commits "author"}}{{this.author}}{{#unless @last}}, {{/unless}}{{/unique}}

76
templates/readme.md Normal file
View File

@@ -0,0 +1,76 @@
# {{project.name}}
{{#if project.description}}
{{project.description}}
{{/if}}
## Overview
This project contains {{project.modules.length}} modules with a total of {{project.metrics.total_lines}} lines of code.
## Installation
```bash
cargo install {{project.name}}
```
## Usage
```bash
{{project.name}} --help
```
## Dependencies
{{#each project.dependencies}}
- `{{@key}}`: {{this}}
{{/each}}
## Project Structure
```
{{#each project.structure.directories}}
{{this.name}}/
{{/each}}
```
## API Documentation
{{#each project.modules}}
### {{this.name}}
{{#if this.docs}}
{{this.docs}}
{{/if}}
{{#if this.functions}}
**Functions:** {{this.functions.length}}
{{/if}}
{{#if this.structs}}
**Structs:** {{this.structs.length}}
{{/if}}
{{/each}}
## Metrics
- **Lines of Code:** {{project.metrics.total_lines}}
- **Total Files:** {{project.metrics.total_files}}
- **Test Files:** {{project.metrics.test_files}}
- **Dependencies:** {{project.metrics.dependency_count}}
- **Complexity Score:** {{project.metrics.complexity_score}}
## License
{{#if project.license}}
{{project.license}}
{{else}}
MIT
{{/if}}
## Authors
{{#each project.authors}}
- {{this}}
{{/each}}

39
templates/structure.md Normal file
View File

@@ -0,0 +1,39 @@
# Project Structure
## Directory Overview
```
{{#each structure.directories}}
{{this.name}}/
{{#each this.subdirectories}}
├── {{this}}/
{{/each}}
{{#if this.file_count}}
└── ({{this.file_count}} files)
{{/if}}
{{/each}}
```
## File Distribution
{{#each structure.files}}
- **{{this.name}}** ({{this.language}}) - {{this.lines_of_code}} lines{{#if this.is_test}} [TEST]{{/if}}
{{/each}}
## Statistics
- **Total Directories:** {{structure.directories.length}}
- **Total Files:** {{structure.files.length}}
- **Languages Used:**
{{#group structure.files by="language"}}
- {{@key}}: {{this.length}} files
{{/group}}
{{#if structure.dependency_graph}}
## Dependencies
{{#each structure.dependency_graph}}
- **{{@key}}** depends on: {{#each this}}{{this}}{{#unless @last}}, {{/unless}}{{/each}}
{{/each}}
{{/if}}

19
vercel.json Normal file
View File

@@ -0,0 +1,19 @@
{
"version": 2,
"builds": [
{
"src": "my-blog/public/**",
"use": "@vercel/static"
}
],
"routes": [
{
"src": "/api/ask",
"dest": "/api/ask.js"
},
{
"src": "/(.*)",
"dest": "/my-blog/public/$1"
}
]
}

93
workers/ollama-proxy.js Normal file
View File

@@ -0,0 +1,93 @@
// Cloudflare Worker for secure Ollama proxy
export default {
async fetch(request, env, ctx) {
// CORS preflight
if (request.method === 'OPTIONS') {
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': 'https://log.syui.ai',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, X-User-Token',
'Access-Control-Max-Age': '86400',
}
});
}
// Verify origin
const origin = request.headers.get('Origin');
const referer = request.headers.get('Referer');
// 許可されたオリジンのみ
const allowedOrigins = [
'https://log.syui.ai',
'https://log.pages.dev' // Cloudflare Pages preview
];
if (!origin || !allowedOrigins.some(allowed => origin.startsWith(allowed))) {
return new Response('Forbidden', { status: 403 });
}
// ユーザー認証トークン検証(オプション)
const userToken = request.headers.get('X-User-Token');
if (env.REQUIRE_AUTH && !userToken) {
return new Response('Unauthorized', { status: 401 });
}
// リクエストボディを取得
const body = await request.json();
// プロンプトサイズ制限
if (body.prompt && body.prompt.length > 1000) {
return new Response(JSON.stringify({
error: 'Prompt too long. Maximum 1000 characters.'
}), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
// レート制限CF Workers KV使用
if (env.RATE_LIMITER) {
const clientIP = request.headers.get('CF-Connecting-IP');
const rateLimitKey = `rate:${clientIP}`;
const currentCount = await env.RATE_LIMITER.get(rateLimitKey) || 0;
if (currentCount >= 20) {
return new Response(JSON.stringify({
error: 'Rate limit exceeded. Try again later.'
}), {
status: 429,
headers: { 'Content-Type': 'application/json' }
});
}
// カウント増加1時間TTL
await env.RATE_LIMITER.put(rateLimitKey, currentCount + 1, {
expirationTtl: 3600
});
}
// Ollamaへプロキシ
const ollamaResponse = await fetch(env.OLLAMA_API_URL || 'https://ollama.syui.ai/api/generate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// 内部認証ヘッダー(必要に応じて)
'X-Internal-Token': env.OLLAMA_INTERNAL_TOKEN || ''
},
body: JSON.stringify(body)
});
// レスポンスを返す
const responseData = await ollamaResponse.text();
return new Response(responseData, {
status: ollamaResponse.status,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': origin,
'Cache-Control': 'no-store'
}
});
}
};

20
workers/wrangler.toml Normal file
View File

@@ -0,0 +1,20 @@
name = "ollama-proxy"
main = "ollama-proxy.js"
compatibility_date = "2024-01-01"
# 環境変数
[vars]
REQUIRE_AUTH = false
# 本番環境
[env.production.vars]
OLLAMA_API_URL = "https://ollama.syui.ai/api/generate"
REQUIRE_AUTH = true
# KVネームスペースレート制限用
[[kv_namespaces]]
binding = "RATE_LIMITER"
id = "your-kv-namespace-id"
# シークレットwrangler secret putで設定
# OLLAMA_INTERNAL_TOKEN = "your-internal-token"

31
wrangler.toml Normal file
View File

@@ -0,0 +1,31 @@
name = "ailog"
compatibility_date = "2024-01-01"
[env.production]
name = "ailog"
[build]
command = "cargo build --release && ./target/release/ailog build my-blog"
publish = "my-blog/public"
[[redirects]]
from = "/api/ask"
to = "https://ai-gpt-mcp.your-domain.com/ask"
status = 200
[[headers]]
for = "/*"
[headers.values]
X-Frame-Options = "DENY"
X-Content-Type-Options = "nosniff"
Referrer-Policy = "strict-origin-when-cross-origin"
[[headers]]
for = "/css/*"
[headers.values]
Cache-Control = "public, max-age=31536000, immutable"
[[headers]]
for = "*.js"
[headers.values]
Cache-Control = "public, max-age=31536000, immutable"