Compare commits
2 Commits
26b1b2cf87
...
73b5982b36
Author | SHA1 | Date | |
---|---|---|---|
73b5982b36
|
|||
7791399314
|
@@ -39,6 +39,8 @@ urlencoding = "2.1"
|
||||
axum = "0.7"
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.5", features = ["cors", "fs"] }
|
||||
axum-extra = { version = "0.9", features = ["typed-header"] }
|
||||
tracing = "0.1"
|
||||
hyper = { version = "1.0", features = ["full"] }
|
||||
tower-sessions = "0.12"
|
||||
jsonwebtoken = "9.2"
|
||||
|
@@ -200,7 +200,7 @@ a.view-markdown:any-link {
|
||||
/* Main Content */
|
||||
.main-content {
|
||||
grid-area: main;
|
||||
max-width: 1000px;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 0px;
|
||||
width: 100%;
|
||||
@@ -969,6 +969,41 @@ article.article-content {
|
||||
.question-form {
|
||||
padding: 12px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Profile Display Styles */
|
||||
.profile-avatar-fallback {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--theme-color);
|
||||
color: var(--white);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.admin-badge {
|
||||
background: var(--theme-color);
|
||||
color: var(--white);
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
font-weight: 500;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.loading-message, .error-message, .no-profiles {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: var(--dark-gray);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #d32f2f;
|
||||
}
|
||||
|
||||
.input-container {
|
||||
flex-direction: column !important;
|
||||
|
@@ -37,10 +37,74 @@ function checkAuthenticationStatus() {
|
||||
document.getElementById('aiQuestion').focus();
|
||||
}, 50);
|
||||
} else {
|
||||
// User not authenticated - show auth message
|
||||
document.getElementById('authCheck').style.display = 'block';
|
||||
// User not authenticated - show profiles instead of auth message
|
||||
document.getElementById('authCheck').style.display = 'none';
|
||||
document.getElementById('chatForm').style.display = 'none';
|
||||
document.getElementById('chatHistory').style.display = 'none';
|
||||
document.getElementById('chatHistory').style.display = 'block';
|
||||
loadAndShowProfiles();
|
||||
}
|
||||
}
|
||||
|
||||
// Load and display profiles from ai.syui.log.profile collection
|
||||
async function loadAndShowProfiles() {
|
||||
const chatHistory = document.getElementById('chatHistory');
|
||||
chatHistory.innerHTML = '<div class="loading-message">Loading profiles...</div>';
|
||||
|
||||
try {
|
||||
const ADMIN_HANDLE = 'ai.syui.ai';
|
||||
const OAUTH_COLLECTION = 'ai.syui.log';
|
||||
const ATPROTO_PDS = 'syu.is';
|
||||
|
||||
const response = await fetch(`https://${ATPROTO_PDS}/xrpc/com.atproto.repo.listRecords?repo=${ADMIN_HANDLE}&collection=${OAUTH_COLLECTION}.profile&limit=100`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch profiles');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const profiles = (data.records || []).sort((a, b) => {
|
||||
if (a.value.type === 'admin' && b.value.type !== 'admin') return -1;
|
||||
if (a.value.type !== 'admin' && b.value.type === 'admin') return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
// Clear loading message
|
||||
chatHistory.innerHTML = '';
|
||||
|
||||
// Display profiles using the same format as chat
|
||||
profiles.forEach(profile => {
|
||||
const profileDiv = document.createElement('div');
|
||||
profileDiv.className = 'chat-message ai-message comment-style';
|
||||
|
||||
const avatarElement = profile.value.author.avatar
|
||||
? `<img src="${profile.value.author.avatar}" alt="${profile.value.author.displayName || profile.value.author.handle}" class="profile-avatar">`
|
||||
: `<div class="profile-avatar-fallback">${(profile.value.author.displayName || profile.value.author.handle || '?').charAt(0).toUpperCase()}</div>`;
|
||||
|
||||
const adminBadge = profile.value.type === 'admin'
|
||||
? '<span class="admin-badge">Admin</span>'
|
||||
: '';
|
||||
|
||||
profileDiv.innerHTML = `
|
||||
<div class="message-header">
|
||||
<div class="avatar">${avatarElement}</div>
|
||||
<div class="user-info">
|
||||
<div class="display-name">${profile.value.author.displayName || profile.value.author.handle} ${adminBadge}</div>
|
||||
<div class="handle">@${profile.value.author.handle}</div>
|
||||
<div class="timestamp">${new Date(profile.value.createdAt).toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-content">${profile.value.text}</div>
|
||||
`;
|
||||
chatHistory.appendChild(profileDiv);
|
||||
});
|
||||
|
||||
if (profiles.length === 0) {
|
||||
chatHistory.innerHTML = '<div class="no-profiles">No profiles available</div>';
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading profiles:', error);
|
||||
chatHistory.innerHTML = '<div class="error-message">Failed to load profiles. Please try again later.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -34,6 +34,96 @@ body {
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
/* Profile Form Styles */
|
||||
.profile-form-container {
|
||||
background: var(--background-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.profile-form-container h3 {
|
||||
margin: 0 0 16px 0;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.profile-form .form-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.profile-form .form-group {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.profile-form .form-group label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.profile-form .form-group input,
|
||||
.profile-form .form-group select,
|
||||
.profile-form .form-group textarea {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.profile-form .form-group input:focus,
|
||||
.profile-form .form-group select:focus,
|
||||
.profile-form .form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.profile-form .form-group textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.profile-form .submit-btn {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.profile-form .submit-btn:hover:not(:disabled) {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.profile-form .submit-btn:disabled {
|
||||
background: var(--text-secondary);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Profile Record List Styles */
|
||||
.profile-record-list .record-item.admin {
|
||||
border-left: 4px solid var(--primary);
|
||||
}
|
||||
|
||||
.profile-record-list .admin-badge {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
font-weight: 500;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.oauth-app-header {
|
||||
background: var(--background);
|
||||
|
@@ -7,6 +7,7 @@ import { usePageContext } from './hooks/usePageContext.js'
|
||||
import AuthButton from './components/AuthButton.jsx'
|
||||
import RecordTabs from './components/RecordTabs.jsx'
|
||||
import CommentForm from './components/CommentForm.jsx'
|
||||
import ProfileForm from './components/ProfileForm.jsx'
|
||||
import AskAI from './components/AskAI.jsx'
|
||||
import TestUI from './components/TestUI.jsx'
|
||||
import OAuthCallback from './components/OAuthCallback.jsx'
|
||||
@@ -428,6 +429,20 @@ Answer:`
|
||||
</div>
|
||||
)}
|
||||
|
||||
{user && (
|
||||
<div className="profile-form">
|
||||
<ProfileForm
|
||||
user={user}
|
||||
agent={agent}
|
||||
apiConfig={adminData.apiConfig}
|
||||
onProfilePosted={() => {
|
||||
refreshAdminData?.()
|
||||
refreshUserData?.()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<RecordTabs
|
||||
langRecords={langRecords}
|
||||
commentRecords={commentRecords}
|
||||
|
@@ -179,6 +179,16 @@ export const collections = {
|
||||
return data
|
||||
},
|
||||
|
||||
async getProfiles(pds, repo, collection, limit = 100) {
|
||||
const cacheKey = dataCache.generateKey('profiles', pds, repo, collection, limit)
|
||||
const cached = dataCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
const data = await atproto.getRecords(pds, repo, `${collection}.profile`, limit)
|
||||
dataCache.set(cacheKey, data)
|
||||
return data
|
||||
},
|
||||
|
||||
// 投稿後にキャッシュを無効化
|
||||
invalidateCache(collection) {
|
||||
dataCache.invalidatePattern(collection)
|
||||
|
160
oauth/src/components/ProfileForm.jsx
Normal file
160
oauth/src/components/ProfileForm.jsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import React, { useState } from 'react'
|
||||
import { atproto } from '../api/atproto.js'
|
||||
import { collections } from '../api/atproto.js'
|
||||
|
||||
const ProfileForm = ({ user, agent, apiConfig, onProfilePosted }) => {
|
||||
const [text, setText] = useState('')
|
||||
const [type, setType] = useState('user')
|
||||
const [handle, setHandle] = useState('')
|
||||
const [rkey, setRkey] = useState('')
|
||||
const [posting, setPosting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!text.trim() || !handle.trim() || !rkey.trim()) {
|
||||
setError('すべてのフィールドを入力してください')
|
||||
return
|
||||
}
|
||||
|
||||
setPosting(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
// Get handle information
|
||||
let authorData
|
||||
try {
|
||||
const handleDid = await atproto.getDid(apiConfig.pds, handle)
|
||||
authorData = await atproto.getProfile(apiConfig.bsky, handleDid)
|
||||
} catch (err) {
|
||||
throw new Error('ハンドルが見つかりません')
|
||||
}
|
||||
|
||||
// Create record
|
||||
const record = {
|
||||
repo: user.did,
|
||||
collection: `${apiConfig.collection}.profile`,
|
||||
rkey: rkey,
|
||||
record: {
|
||||
$type: `${apiConfig.collection}.profile`,
|
||||
text: text,
|
||||
type: type,
|
||||
author: {
|
||||
did: authorData.did,
|
||||
handle: authorData.handle,
|
||||
displayName: authorData.displayName || authorData.handle,
|
||||
avatar: authorData.avatar || null
|
||||
},
|
||||
createdAt: new Date().toISOString(),
|
||||
post: {
|
||||
url: window.location.origin,
|
||||
date: new Date().toISOString(),
|
||||
slug: '',
|
||||
tags: [],
|
||||
title: 'Profile',
|
||||
language: 'ja'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await atproto.putRecord(apiConfig.pds, record, agent)
|
||||
|
||||
// Invalidate cache and refresh
|
||||
collections.invalidateCache(`${apiConfig.collection}.profile`)
|
||||
|
||||
// Reset form
|
||||
setText('')
|
||||
setType('user')
|
||||
setHandle('')
|
||||
setRkey('')
|
||||
|
||||
if (onProfilePosted) {
|
||||
onProfilePosted()
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('Failed to create profile:', err)
|
||||
setError(err.message || 'プロフィールの作成に失敗しました')
|
||||
} finally {
|
||||
setPosting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="profile-form-container">
|
||||
<h3>プロフィール投稿</h3>
|
||||
|
||||
{error && (
|
||||
<div className="error-message">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="profile-form">
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label htmlFor="handle">ハンドル</label>
|
||||
<input
|
||||
type="text"
|
||||
id="handle"
|
||||
value={handle}
|
||||
onChange={(e) => setHandle(e.target.value)}
|
||||
placeholder="例: syui.ai"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="rkey">Rkey</label>
|
||||
<input
|
||||
type="text"
|
||||
id="rkey"
|
||||
value={rkey}
|
||||
onChange={(e) => setRkey(e.target.value)}
|
||||
placeholder="例: syui"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="type">タイプ</label>
|
||||
<select
|
||||
id="type"
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value)}
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="text">プロフィールテキスト</label>
|
||||
<textarea
|
||||
id="text"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="プロフィールの説明を入力してください"
|
||||
rows={4}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={posting || !text.trim() || !handle.trim() || !rkey.trim()}
|
||||
className="submit-btn"
|
||||
>
|
||||
{posting ? '投稿中...' : '投稿'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProfileForm
|
133
oauth/src/components/ProfileRecordList.jsx
Normal file
133
oauth/src/components/ProfileRecordList.jsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { collections } from '../api/atproto.js'
|
||||
import AvatarImage from './AvatarImage.jsx'
|
||||
import LoadingSkeleton from './LoadingSkeleton.jsx'
|
||||
|
||||
const ProfileRecordList = ({ apiConfig, user, agent, onRecordDeleted }) => {
|
||||
const [profiles, setProfiles] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (apiConfig?.admin && apiConfig?.collection) {
|
||||
fetchProfiles()
|
||||
}
|
||||
}, [apiConfig])
|
||||
|
||||
const fetchProfiles = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const adminProfiles = await collections.getProfiles(
|
||||
apiConfig.pds,
|
||||
apiConfig.admin,
|
||||
apiConfig.collection
|
||||
)
|
||||
|
||||
// Sort profiles: admin type first, then user type
|
||||
const sortedProfiles = adminProfiles.sort((a, b) => {
|
||||
if (a.value.type === 'admin' && b.value.type !== 'admin') return -1
|
||||
if (a.value.type !== 'admin' && b.value.type === 'admin') return 1
|
||||
return 0
|
||||
})
|
||||
|
||||
setProfiles(sortedProfiles)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch profiles:', err)
|
||||
setError('プロフィールの読み込みに失敗しました')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (uri) => {
|
||||
if (!user || !agent) return
|
||||
if (!confirm('このプロフィールを削除しますか?')) return
|
||||
|
||||
try {
|
||||
const rkey = uri.split('/').pop()
|
||||
await agent.api.com.atproto.repo.deleteRecord({
|
||||
repo: user.did,
|
||||
collection: `${apiConfig.collection}.profile`,
|
||||
rkey: rkey
|
||||
})
|
||||
|
||||
// Invalidate cache and refresh
|
||||
collections.invalidateCache(`${apiConfig.collection}.profile`)
|
||||
await fetchProfiles()
|
||||
|
||||
if (onRecordDeleted) {
|
||||
onRecordDeleted()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to delete profile:', err)
|
||||
setError('プロフィールの削除に失敗しました')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <LoadingSkeleton count={3} showTitle={true} />
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="error-state">
|
||||
<p>{error}</p>
|
||||
<button onClick={fetchProfiles} className="retry-btn">再試行</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (profiles.length === 0) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<p>プロフィールがありません</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="record-list profile-record-list">
|
||||
{profiles.map((profile) => (
|
||||
<div key={profile.uri} className={`record-item comment-style ${profile.value.type}`}>
|
||||
<div className="message-header">
|
||||
<div className="avatar">
|
||||
<AvatarImage
|
||||
src={profile.value.author.avatar}
|
||||
alt={profile.value.author.displayName || profile.value.author.handle}
|
||||
size={40}
|
||||
/>
|
||||
</div>
|
||||
<div className="user-info">
|
||||
<div className="display-name">
|
||||
{profile.value.author.displayName || profile.value.author.handle}
|
||||
{profile.value.type === 'admin' && (
|
||||
<span className="admin-badge">Admin</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="handle">@{profile.value.author.handle}</div>
|
||||
<div className="timestamp">
|
||||
{new Date(profile.value.createdAt).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
{user && (
|
||||
<div className="record-actions">
|
||||
<button
|
||||
onClick={() => handleDelete(profile.uri)}
|
||||
className="delete-btn"
|
||||
title="削除"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="message-content">{profile.value.text}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProfileRecordList
|
@@ -1,6 +1,7 @@
|
||||
import React, { useState } from 'react'
|
||||
import RecordList from './RecordList.jsx'
|
||||
import ChatRecordList from './ChatRecordList.jsx'
|
||||
import ProfileRecordList from './ProfileRecordList.jsx'
|
||||
import LoadingSkeleton from './LoadingSkeleton.jsx'
|
||||
|
||||
export default function RecordTabs({ langRecords, commentRecords, userComments, chatRecords, userChatRecords, userChatLoading, baseRecords, apiConfig, pageContext, user = null, agent = null, onRecordDeleted = null }) {
|
||||
@@ -60,6 +61,12 @@ export default function RecordTabs({ langRecords, commentRecords, userComments,
|
||||
>
|
||||
comment ({filteredUserComments.length})
|
||||
</button>
|
||||
<button
|
||||
className={`tab-btn ${activeTab === 'profiles' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('profiles')}
|
||||
>
|
||||
profiles
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="tab-content">
|
||||
@@ -121,6 +128,14 @@ export default function RecordTabs({ langRecords, commentRecords, userComments,
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{activeTab === 'profiles' && (
|
||||
<ProfileRecordList
|
||||
apiConfig={apiConfig}
|
||||
user={user}
|
||||
agent={agent}
|
||||
onRecordDeleted={onRecordDeleted}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
24
src/main.rs
24
src/main.rs
@@ -79,6 +79,15 @@ enum Commands {
|
||||
/// Path to the blog directory
|
||||
#[arg(default_value = ".")]
|
||||
path: PathBuf,
|
||||
/// Enable Claude proxy mode
|
||||
#[arg(long)]
|
||||
claude_proxy: bool,
|
||||
/// API token for Claude proxy authentication
|
||||
#[arg(long)]
|
||||
api_token: Option<String>,
|
||||
/// Claude Code executable path
|
||||
#[arg(long, default_value = "claude")]
|
||||
claude_code_path: String,
|
||||
},
|
||||
/// Generate documentation from code
|
||||
Doc(commands::doc::DocCommand),
|
||||
@@ -203,9 +212,20 @@ async fn main() -> Result<()> {
|
||||
std::env::set_current_dir(path)?;
|
||||
commands::clean::execute().await?;
|
||||
}
|
||||
Commands::Mcp { port, path } => {
|
||||
Commands::Mcp { port, path, claude_proxy, api_token, claude_code_path } => {
|
||||
use crate::mcp::McpServer;
|
||||
let server = McpServer::new(path);
|
||||
let mut server = McpServer::new(path);
|
||||
|
||||
if claude_proxy {
|
||||
let token = api_token
|
||||
.or_else(|| std::env::var("CLAUDE_PROXY_API_TOKEN").ok())
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!("API token is required when --claude-proxy is enabled. Set CLAUDE_PROXY_API_TOKEN environment variable or use --api-token")
|
||||
})?;
|
||||
server = server.with_claude_proxy(token, Some(claude_code_path.clone()));
|
||||
println!("Claude proxy mode enabled - using Claude Code executable: {}", claude_code_path);
|
||||
}
|
||||
|
||||
server.serve(port).await?;
|
||||
}
|
||||
Commands::Doc(doc_cmd) => {
|
||||
|
156
src/mcp/claude_proxy.rs
Normal file
156
src/mcp/claude_proxy.rs
Normal file
@@ -0,0 +1,156 @@
|
||||
use anyhow::Result;
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::Json,
|
||||
};
|
||||
use axum_extra::{
|
||||
headers::{authorization::Bearer, Authorization},
|
||||
TypedHeader,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
// Removed unused import
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ChatRequest {
|
||||
pub question: String,
|
||||
#[serde(rename = "systemPrompt")]
|
||||
pub system_prompt: String,
|
||||
#[serde(default)]
|
||||
pub context: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ChatResponse {
|
||||
pub answer: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ClaudeProxyState {
|
||||
pub api_token: String,
|
||||
pub claude_code_path: String,
|
||||
}
|
||||
|
||||
pub async fn claude_chat_handler(
|
||||
State(state): State<crate::mcp::server::AppState>,
|
||||
auth: Option<TypedHeader<Authorization<Bearer>>>,
|
||||
Json(request): Json<ChatRequest>,
|
||||
) -> Result<Json<ChatResponse>, StatusCode> {
|
||||
// Claude proxyが有効かチェック
|
||||
let claude_proxy = state.claude_proxy.as_ref().ok_or(StatusCode::NOT_FOUND)?;
|
||||
|
||||
// 認証チェック
|
||||
let auth = auth.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||
if auth.token() != claude_proxy.api_token {
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// Claude CodeのMCP通信実装
|
||||
let response = communicate_with_claude_mcp(
|
||||
&request.question,
|
||||
&request.system_prompt,
|
||||
&request.context,
|
||||
&claude_proxy.claude_code_path,
|
||||
).await?;
|
||||
|
||||
Ok(Json(ChatResponse { answer: response }))
|
||||
}
|
||||
|
||||
async fn communicate_with_claude_mcp(
|
||||
message: &str,
|
||||
system: &str,
|
||||
_context: &Value,
|
||||
claude_code_path: &str,
|
||||
) -> Result<String, StatusCode> {
|
||||
tracing::info!("Communicating with Claude Code via stdio");
|
||||
tracing::info!("Message: {}", message);
|
||||
tracing::info!("System prompt: {}", system);
|
||||
|
||||
// Claude Code MCPプロセスを起動
|
||||
// Use the full path to avoid shell function and don't use --continue
|
||||
let claude_executable = if claude_code_path == "claude" {
|
||||
"/Users/syui/.claude/local/claude"
|
||||
} else {
|
||||
claude_code_path
|
||||
};
|
||||
|
||||
let mut child = tokio::process::Command::new(claude_executable)
|
||||
.args(&["--print", "--output-format", "text"])
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to start Claude Code process: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
// プロンプトを構築
|
||||
let full_prompt = if !system.is_empty() {
|
||||
format!("{}\n\nUser: {}", system, message)
|
||||
} else {
|
||||
message.to_string()
|
||||
};
|
||||
|
||||
// 標準入力にプロンプトを送信
|
||||
if let Some(stdin) = child.stdin.take() {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let mut stdin = stdin;
|
||||
stdin.write_all(full_prompt.as_bytes()).await.map_err(|e| {
|
||||
tracing::error!("Failed to write to Claude Code stdin: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
stdin.shutdown().await.map_err(|e| {
|
||||
tracing::error!("Failed to close Claude Code stdin: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
}
|
||||
|
||||
// プロセス完了を待機(タイムアウト付き)
|
||||
let output = tokio::time::timeout(
|
||||
tokio::time::Duration::from_secs(30),
|
||||
child.wait_with_output()
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
tracing::error!("Claude Code process timed out");
|
||||
StatusCode::REQUEST_TIMEOUT
|
||||
})?
|
||||
.map_err(|e| {
|
||||
tracing::error!("Claude Code process failed: {}", e);
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
})?;
|
||||
|
||||
// プロセス終了ステータスをチェック
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
tracing::error!("Claude Code process failed with stderr: {}", stderr);
|
||||
return Ok("Claude Codeプロセスでエラーが発生しました".to_string());
|
||||
}
|
||||
|
||||
// 標準出力を解析
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
tracing::debug!("Claude Code stdout: {}", stdout);
|
||||
|
||||
// Claude Codeは通常プレーンテキストを返すので、そのまま返す
|
||||
Ok(stdout.trim().to_string())
|
||||
}
|
||||
|
||||
pub async fn claude_tools_handler() -> Json<Value> {
|
||||
Json(json!({
|
||||
"tools": {
|
||||
"chat": {
|
||||
"description": "Chat with Claude",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {"type": "string"},
|
||||
"system": {"type": "string"}
|
||||
},
|
||||
"required": ["message"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
@@ -1,5 +1,6 @@
|
||||
pub mod server;
|
||||
pub mod tools;
|
||||
pub mod types;
|
||||
pub mod claude_proxy;
|
||||
|
||||
pub use server::McpServer;
|
@@ -12,10 +12,12 @@ use std::sync::Arc;
|
||||
use tower_http::cors::CorsLayer;
|
||||
use crate::mcp::tools::BlogTools;
|
||||
use crate::mcp::types::{McpRequest, McpResponse, McpError, CreatePostRequest, ListPostsRequest, BuildRequest};
|
||||
use crate::mcp::claude_proxy::{claude_chat_handler, claude_tools_handler, ClaudeProxyState};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
blog_tools: Arc<BlogTools>,
|
||||
pub blog_tools: Arc<BlogTools>,
|
||||
pub claude_proxy: Option<Arc<ClaudeProxyState>>,
|
||||
}
|
||||
|
||||
pub struct McpServer {
|
||||
@@ -25,17 +27,31 @@ pub struct McpServer {
|
||||
impl McpServer {
|
||||
pub fn new(base_path: PathBuf) -> Self {
|
||||
let blog_tools = Arc::new(BlogTools::new(base_path));
|
||||
let app_state = AppState { blog_tools };
|
||||
let app_state = AppState {
|
||||
blog_tools,
|
||||
claude_proxy: None,
|
||||
};
|
||||
|
||||
Self { app_state }
|
||||
}
|
||||
|
||||
pub fn with_claude_proxy(mut self, api_token: String, claude_code_path: Option<String>) -> Self {
|
||||
let claude_code_path = claude_code_path.unwrap_or_else(|| "claude".to_string());
|
||||
self.app_state.claude_proxy = Some(Arc::new(ClaudeProxyState {
|
||||
api_token,
|
||||
claude_code_path,
|
||||
}));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn create_router(&self) -> Router {
|
||||
Router::new()
|
||||
.route("/", get(root_handler))
|
||||
.route("/mcp/tools/list", get(list_tools))
|
||||
.route("/mcp/tools/call", post(call_tool))
|
||||
.route("/health", get(health_check))
|
||||
.route("/api/claude-mcp", post(claude_chat_handler))
|
||||
.route("/claude/tools", get(claude_tools_handler))
|
||||
.layer(CorsLayer::permissive())
|
||||
.with_state(self.app_state.clone())
|
||||
}
|
||||
|
Reference in New Issue
Block a user