4 Commits

Author SHA1 Message Date
833549756b fix did check 2025-06-17 22:36:33 +09:00
f0fdf678c8 fix oauth plc 2025-06-17 17:43:03 +09:00
820e47f634 update binary 2025-06-17 11:01:42 +09:00
4dac4a83e0 fix atproto web link 2025-06-17 11:00:09 +09:00
11 changed files with 97 additions and 211 deletions

1
.gitignore vendored
View File

@@ -17,3 +17,4 @@ my-blog/templates/oauth-assets.html
cloudflared-config.yml
.config
oauth-server-example
atproto

Binary file not shown.

View File

@@ -16,8 +16,10 @@ auto_translate = false
comment_moderation = false
ask_ai = true
provider = "ollama"
model = "gemma3:4b"
host = "https://localhost:11434"
model = "qwen3"
model_translation = "llama3.2:1b"
model_technical = "phi3:mini"
host = "http://localhost:11434"
system_prompt = "あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"
handle = "ai.syui.ai"
#num_predict = 200

View File

@@ -16,5 +16,5 @@ VITE_AI_ENABLED=true
VITE_AI_ASK_AI=true
VITE_AI_PROVIDER=ollama
VITE_AI_MODEL=gemma3:4b
VITE_AI_HOST=https://localhost:11434
VITE_AI_HOST=http://localhost:11434
VITE_AI_SYSTEM_PROMPT="あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"

View File

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

View File

@@ -124,17 +124,8 @@ function App() {
loadAIGeneratedContent();
};
// Wait for DID resolution before loading data
if (adminDid && aiDid) {
loadDataAfterDidResolution();
} else {
// Wait a bit and try again
setTimeout(() => {
if (adminDid && aiDid) {
loadDataAfterDidResolution();
}
}, 1000);
}
// Load data immediately with fallback DIDs (skip DID resolution wait)
loadDataAfterDidResolution();
// Load AI profile from handle
const loadAiProfile = async () => {
@@ -224,13 +215,7 @@ function App() {
// Ensure handle is not DID
const handle = oauthResult.handle !== oauthResult.did ? oauthResult.handle : oauthResult.handle;
// Check if handle is allowed
if (appConfig.allowedHandles.length > 0 && !appConfig.allowedHandles.includes(handle)) {
// Handle not in allowed list
setError(`Access denied: ${handle} is not authorized for this application.`);
setIsLoading(false);
return;
}
// Note: appConfig.allowedHandles is used for PDS detection, not access control
// Get user profile including avatar
const userProfile = await getUserProfile(oauthResult.did, handle);
@@ -338,8 +323,8 @@ function App() {
// Load all chat records from users in admin's user list
const currentAdminDid = adminDid || appConfig.adminDid;
// Don't proceed if we don't have a valid DID
if (!currentAdminDid || !isValidDid(currentAdminDid)) {
// Use fallback DID if resolution failed
if (!currentAdminDid) {
return;
}
@@ -457,8 +442,8 @@ function App() {
try {
const currentAdminDid = adminDid || appConfig.adminDid;
// Don't proceed if we don't have a valid DID
if (!currentAdminDid || !isValidDid(currentAdminDid)) {
// Use fallback DID if resolution failed
if (!currentAdminDid) {
return;
}
@@ -550,6 +535,13 @@ function App() {
const profileResponse = await fetch(`${apiEndpoint}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`);
if (profileResponse.ok) {
const profileData = await profileResponse.json();
// Determine correct web URL based on avatar source
let webUrl = config.webUrl; // Default from handle-based detection
if (profileData.avatar && profileData.avatar.includes('cdn.bsky.app')) {
webUrl = 'https://bsky.app'; // Override to Bluesky if avatar is from Bluesky
}
return {
...record,
value: {
@@ -559,7 +551,7 @@ function App() {
avatar: profileData.avatar,
displayName: profileData.displayName || handle,
_pdsEndpoint: `https://${pds}`, // Store PDS info for later use
_webUrl: config.webUrl, // Store web URL for profile links
_webUrl: webUrl, // Store corrected web URL for profile links
}
}
};
@@ -810,6 +802,14 @@ function App() {
const profile = await import('./utils/pds-detection').then(m => m.getProfileForUser(record.value.author.handle));
if (profile) {
// Determine network config based on profile data
let webUrl = 'https://bsky.app'; // Default to Bluesky
if (profile.avatar && profile.avatar.includes('cdn.bsky.app')) {
webUrl = 'https://bsky.app';
} else if (profile.avatar && profile.avatar.includes('bsky.syu.is')) {
webUrl = 'https://web.syu.is';
}
return {
...record,
value: {
@@ -818,6 +818,7 @@ function App() {
...record.value.author,
avatar: profile.avatar,
displayName: profile.displayName || record.value.author.handle,
_webUrl: webUrl, // Store network config for profile URL generation
}
}
};
@@ -1146,7 +1147,18 @@ function App() {
return `${config.webUrl}/profile/${author.did}`;
}
// Get PDS from handle for other users
// For other users, detect network based on avatar URL or stored network info
if (author.avatar && author.avatar.includes('cdn.bsky.app')) {
// User has Bluesky avatar, use Bluesky web interface
return `https://bsky.app/profile/${author.did}`;
}
// Check if we have stored network config from profile fetching
if (author._webUrl) {
return `${author._webUrl}/profile/${author.did}`;
}
// Fallback: Get PDS from handle for other users
const pds = detectPdsFromHandle(author.handle);
const config = getNetworkConfig(pds);
@@ -1186,8 +1198,9 @@ function App() {
// Extract content based on format
const contentText = isNewFormat ? value.text : (value.content || value.body || '');
// For AI comments, always use the loaded AI profile instead of record.value.author
const authorInfo = aiProfile;
// Use the author from the record if available, otherwise fall back to AI profile
const authorInfo = value.author || aiProfile;
const postInfo = isNewFormat ? value.post : null;
const contentType = value.type || 'unknown';
const createdAt = value.createdAt || value.generated_at || '';

View File

@@ -191,6 +191,7 @@ Answer:`;
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Origin': 'https://syui.ai',
},
body: JSON.stringify({
model: aiConfig.model,

View File

@@ -113,7 +113,7 @@ export function getAppConfig(): AppConfig {
const aiEnabled = import.meta.env.VITE_AI_ENABLED === 'true';
const aiAskAi = import.meta.env.VITE_AI_ASK_AI === 'true';
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 || 'gemma3:4b';
const aiHost = import.meta.env.VITE_AI_HOST || 'https://ollama.syui.ai';
const aiSystemPrompt = import.meta.env.VITE_AI_SYSTEM_PROMPT || 'You are a helpful AI assistant trained on this blog\'s content.';
const atprotoPds = import.meta.env.VITE_ATPROTO_PDS || 'syu.is';

View File

@@ -12,6 +12,7 @@ interface AtprotoSession {
class AtprotoOAuthService {
private oauthClient: BrowserOAuthClient | null = null;
private oauthClientSyuIs: BrowserOAuthClient | null = null;
private agent: Agent | null = null;
private initializePromise: Promise<void> | null = null;
@@ -31,22 +32,27 @@ class AtprotoOAuthService {
private async _doInitialize(): Promise<void> {
try {
// Generate client ID based on current origin
const clientId = this.getClientId();
// Support multiple PDS hosts for OAuth
// Initialize both OAuth clients
this.oauthClient = await BrowserOAuthClient.load({
clientId: clientId,
handleResolver: 'https://bsky.social', // Default resolver
handleResolver: 'https://bsky.social',
plcDirectoryUrl: 'https://plc.directory',
});
this.oauthClientSyuIs = await BrowserOAuthClient.load({
clientId: clientId,
handleResolver: 'https://syu.is',
plcDirectoryUrl: 'https://plc.syu.is',
});
// Try to restore existing session
const result = await this.oauthClient.init();
// Try to restore existing session from either client
let result = await this.oauthClient.init();
if (!result?.session) {
result = await this.oauthClientSyuIs.init();
}
if (result?.session) {
// Create Agent instance with proper configuration
@@ -92,41 +98,13 @@ class AtprotoOAuthService {
}
private async processSession(session: any): Promise<{ did: string; handle: string }> {
// Log full session structure
// Check if agent has properties we can access
if (session.agent) {
}
const did = session.sub || session.did;
let handle = session.handle || 'unknown';
// Create Agent directly with session (per official docs)
try {
this.agent = new Agent(session);
// Check if agent has session info after creation
if (this.agent.session) {
}
} catch (err) {
// Fallback to dpopFetch method
this.agent = new Agent({
service: session.server?.serviceEndpoint || 'https://bsky.social',
@@ -204,61 +182,15 @@ class AtprotoOAuthService {
return `${origin}/client-metadata.json`;
}
private async detectPDSFromHandle(handle: string): Promise<string> {
// Handle detection for OAuth PDS routing
// Check if handle ends with known PDS domains first
const pdsMapping = {
'syu.is': 'https://syu.is',
'bsky.social': 'https://bsky.social',
};
for (const [domain, pdsUrl] of Object.entries(pdsMapping)) {
if (handle.endsWith(`.${domain}`)) {
// Using PDS for domain match
return pdsUrl;
}
}
// For handles that don't match domain patterns, resolve via API
try {
// Try to resolve handle to get the actual PDS
const endpoints = ['https://syu.is', 'https://bsky.social'];
for (const endpoint of endpoints) {
try {
const response = await fetch(`${endpoint}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`);
if (response.ok) {
const data = await response.json();
if (data.did) {
console.log('[OAuth Debug] Resolved handle via', endpoint, '- using that PDS');
return endpoint;
}
}
} catch (e) {
continue;
}
}
} catch (e) {
console.log('[OAuth Debug] Handle resolution failed, using default');
}
// Default to bsky.social
// Using default bsky.social
return 'https://bsky.social';
}
async initiateOAuthFlow(handle?: string): Promise<void> {
try {
if (!this.oauthClient) {
if (!this.oauthClient || !this.oauthClientSyuIs) {
await this.initialize();
}
if (!this.oauthClient) {
throw new Error('Failed to initialize OAuth client');
if (!this.oauthClient || !this.oauthClientSyuIs) {
throw new Error('Failed to initialize OAuth clients');
}
// If handle is not provided, prompt user
@@ -269,61 +201,27 @@ class AtprotoOAuthService {
}
}
// Detect PDS based on handle
const pdsUrl = await this.detectPDSFromHandle(handle);
// Starting OAuth flow
// Always re-initialize OAuth client with detected PDS
// Re-initializing OAuth client
// Clear existing client to force fresh initialization
this.oauthClient = null;
this.initializePromise = null;
this.oauthClient = await BrowserOAuthClient.load({
clientId: this.getClientId(),
handleResolver: pdsUrl,
});
// OAuth client initialized
// Start OAuth authorization flow
// Determine which OAuth client to use
const allowedHandlesStr = import.meta.env.VITE_ATPROTO_HANDLE_LIST || '[]';
let allowedHandles: string[] = [];
try {
// Starting OAuth authorization
// Try to authorize with DID instead of handle for syu.is PDS only
let authTarget = handle;
if (pdsUrl === 'https://syu.is') {
try {
const resolveResponse = await fetch(`${pdsUrl}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`);
if (resolveResponse.ok) {
const resolveData = await resolveResponse.json();
authTarget = resolveData.did;
// Using DID for syu.is OAuth workaround
}
} catch (e) {
// Could not resolve to DID, using handle
}
}
const authUrl = await this.oauthClient.authorize(authTarget, {
scope: 'atproto transition:generic',
});
// Redirect to authorization server
window.location.href = authUrl.toString();
} catch (authorizeError) {
// Authorization failed
throw authorizeError;
allowedHandles = JSON.parse(allowedHandlesStr);
} catch {
allowedHandles = [];
}
} catch (error) {
const usesSyuIs = handle.endsWith('.syu.is') || allowedHandles.includes(handle);
const oauthClient = usesSyuIs ? this.oauthClientSyuIs : this.oauthClient;
// Start OAuth authorization flow
const authUrl = await oauthClient.authorize(handle, {
scope: 'atproto transition:generic',
});
// Redirect to authorization server
window.location.href = authUrl.toString();
} catch (error) {
throw new Error(`OAuth認証の開始に失敗しました: ${error}`);
}
}
@@ -379,21 +277,15 @@ class AtprotoOAuthService {
async checkSession(): Promise<{ did: string; handle: string } | null> {
try {
if (!this.oauthClient) {
await this.initialize();
}
if (!this.oauthClient) {
return null;
}
const result = await this.oauthClient.init();
if (result?.session) {
// Use the common session processing method
@@ -458,28 +350,20 @@ class AtprotoOAuthService {
async logout(): Promise<void> {
try {
// Clear Agent
this.agent = null;
// Clear BrowserOAuthClient session
if (this.oauthClient) {
try {
// BrowserOAuthClient may have a revoke or signOut method
if (typeof (this.oauthClient as any).signOut === 'function') {
await (this.oauthClient as any).signOut();
} else if (typeof (this.oauthClient as any).revoke === 'function') {
await (this.oauthClient as any).revoke();
} else {
}
} catch (oauthError) {
// Ignore logout errors
}
// Reset the OAuth client to force re-initialization
@@ -491,18 +375,16 @@ class AtprotoOAuthService {
localStorage.removeItem('atproto_session');
sessionStorage.clear();
// Clear all localStorage items that might be related to OAuth
const keysToRemove: string[] = [];
// Clear all OAuth-related storage
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && (key.includes('oauth') || key.includes('atproto') || key.includes('session'))) {
keysToRemove.push(key);
localStorage.removeItem(key);
}
}
keysToRemove.forEach(key => {
localStorage.removeItem(key);
});
// Clear internal session info
(this as any)._sessionInfo = null;

View File

@@ -3,10 +3,10 @@
set -e
cb=ai.syui.log
cl=( $cb.user )
cl=( $cb.chat.lang $cb.chat.comment)
f=~/.config/syui/ai/log/config.json
default_collection="ai.syui.log.chat.comment"
default_collection="ai.syui.log.chat"
default_pds="syu.is"
default_did=`cat $f|jq -r .admin.did`
default_token=`cat $f|jq -r .admin.access_jwt`

View File

@@ -1426,21 +1426,8 @@ async fn generate_ai_content(content: &str, prompt_type: &str, ai_config: &AiCon
.timeout(std::time::Duration::from_secs(120)) // 2 minute timeout
.build()?;
// Try localhost first (for same-server deployment)
let localhost_url = "http://localhost:11434/api/generate";
match client.post(localhost_url).json(&request).send().await {
Ok(response) if response.status().is_success() => {
let ollama_response: OllamaResponse = response.json().await?;
println!("{}", "✅ Used localhost Ollama".green());
return Ok(ollama_response.response);
}
_ => {
println!("{}", "⚠️ Localhost Ollama not available, trying remote...".yellow());
}
}
// Fallback to remote host
let remote_url = format!("{}/api/generate", ai_config.ollama_host);
// Use configured Ollama host
let ollama_url = format!("{}/api/generate", ai_config.ollama_host);
// Check if this is a local/private network connection (no CORS needed)
// RFC 1918 private networks + localhost
@@ -1461,13 +1448,13 @@ async fn generate_ai_content(content: &str, prompt_type: &str, ai_config: &AiCon
} else { false }
});
let mut request_builder = client.post(&remote_url).json(&request);
let mut request_builder = client.post(&ollama_url).json(&request);
if !is_local {
println!("{}", format!("🔗 Making request to: {} with Origin: {}", remote_url, ai_config.blog_host).blue());
println!("{}", format!("🔗 Making request to: {} with Origin: {}", ollama_url, ai_config.blog_host).blue());
request_builder = request_builder.header("Origin", &ai_config.blog_host);
} else {
println!("{}", format!("🔗 Making request to local network: {}", remote_url).blue());
println!("{}", format!("🔗 Making request to local network: {}", ollama_url).blue());
}
let response = request_builder.send().await?;
@@ -1477,7 +1464,7 @@ async fn generate_ai_content(content: &str, prompt_type: &str, ai_config: &AiCon
}
let ollama_response: OllamaResponse = response.json().await?;
println!("{}", "Used remote Ollama".green());
println!("{}", "Ollama request successful".green());
Ok(ollama_response.response)
}