test cleanup
This commit is contained in:
		@@ -51,7 +51,8 @@
 | 
			
		||||
      "Bash(ailog:*)",
 | 
			
		||||
      "WebFetch(domain:plc.directory)",
 | 
			
		||||
      "WebFetch(domain:atproto.com)",
 | 
			
		||||
      "WebFetch(domain:syu.is)"
 | 
			
		||||
      "WebFetch(domain:syu.is)",
 | 
			
		||||
      "Bash(sed:*)"
 | 
			
		||||
    ],
 | 
			
		||||
    "deny": []
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										208
									
								
								claude.md
									
									
									
									
									
								
							
							
						
						
									
										208
									
								
								claude.md
									
									
									
									
									
								
							@@ -14,6 +14,214 @@ VITE_OAUTH_COLLECTION_USER=ai.syui.log.user
 | 
			
		||||
VITE_OAUTH_COLLECTION_CHAT=ai.syui.log.chat
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## oauth appの設計
 | 
			
		||||
 | 
			
		||||
> ./oauth/.env.production
 | 
			
		||||
 | 
			
		||||
```sh
 | 
			
		||||
VITE_ATPROTO_PDS=syu.is
 | 
			
		||||
VITE_ADMIN_HANDLE=ai.syui.ai
 | 
			
		||||
VITE_AI_HANDLE=ai.syui.ai
 | 
			
		||||
VITE_OAUTH_COLLECTION=ai.syui.log
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
これらは非常にシンプルな流れになっており、すべての項目は、共通します。短縮できる場合があります。handleは変わる可能性があるので、できる限りdidを使いましょう。
 | 
			
		||||
 | 
			
		||||
1. handleからpds, didを取得できる ... com.atproto.repo.describeRepo
 | 
			
		||||
2. pdsが分かれば、pdsApi, bskyApi, plcApiを割り当てられる
 | 
			
		||||
3. bskyApiが分かれば、getProfileでavatar-uriを取得できる ... app.bsky.actor.getProfile
 | 
			
		||||
4. pdsAPiからアカウントにあるcollectionのrecordの情報を取得できる ... com.atproto.repo.listRecords
 | 
			
		||||
 | 
			
		||||
### コメントを表示する
 | 
			
		||||
 | 
			
		||||
1. VITE_ADMIN_HANDLEから管理者のhandleを取得する。
 | 
			
		||||
2. VITE_ATPROTO_PDSから管理者のアカウントのpdsを取得する。
 | 
			
		||||
3. pdsからpdsApi, bskApi, plcApiを割り当てる。
 | 
			
		||||
 | 
			
		||||
```rust
 | 
			
		||||
    match pds {
 | 
			
		||||
        "bsky.social" | "bsky.app" => NetworkConfig {
 | 
			
		||||
            pds_api: format!("https://{}", pds),
 | 
			
		||||
            plc_api: "https://plc.directory".to_string(),
 | 
			
		||||
            bsky_api: "https://public.api.bsky.app".to_string(),
 | 
			
		||||
            web_url: "https://bsky.app".to_string(),
 | 
			
		||||
        },
 | 
			
		||||
        "syu.is" => NetworkConfig {
 | 
			
		||||
            pds_api: "https://syu.is".to_string(),
 | 
			
		||||
            plc_api: "https://plc.syu.is".to_string(),
 | 
			
		||||
            bsky_api: "https://bsky.syu.is".to_string(),
 | 
			
		||||
            web_url: "https://web.syu.is".to_string(),
 | 
			
		||||
        },
 | 
			
		||||
        _ => {
 | 
			
		||||
            // Default to Bluesky network for unknown PDS
 | 
			
		||||
            NetworkConfig {
 | 
			
		||||
                pds_api: format!("https://{}", pds),
 | 
			
		||||
                plc_api: "https://plc.directory".to_string(),
 | 
			
		||||
                bsky_api: "https://public.api.bsky.app".to_string(),
 | 
			
		||||
                web_url: "https://bsky.app".to_string(),
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
4. 管理者アカウントであるVITE_ADMIN_HANDLEとVITE_ATPROTO_PDSから`ai.syui.log.user`というuserlistを取得する。
 | 
			
		||||
 | 
			
		||||
```sh
 | 
			
		||||
curl -sL "https://${VITE_ATPROTO_PDS}/xrpc/com.atproto.repo.listRecords?repo=${VITE_ADMIN_HANDLE}&collection=ai.syui.log.user" 
 | 
			
		||||
---
 | 
			
		||||
syui.ai
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
5. ユーザーがわかったら、そのユーザーのpdsを判定する。
 | 
			
		||||
 | 
			
		||||
```sh
 | 
			
		||||
curl -sL "https://bsky.social/xrpc/com.atproto.repo.describeRepo?repo=syui.ai" |jq -r ".didDoc.service.[].serviceEndpoint"
 | 
			
		||||
---
 | 
			
		||||
https://shiitake.us-east.host.bsky.network
 | 
			
		||||
 | 
			
		||||
curl -sL "https://bsky.social/xrpc/com.atproto.repo.describeRepo?repo=syui.ai" |jq -r ".did"
 | 
			
		||||
---
 | 
			
		||||
did:plc:uqzpqmrjnptsxezjx4xuh2mn
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
6. pdsからpdsApi, bskApi, plcApiを割り当てる。
 | 
			
		||||
 | 
			
		||||
```rust
 | 
			
		||||
    match pds {
 | 
			
		||||
        "bsky.social" | "bsky.app" => NetworkConfig {
 | 
			
		||||
            pds_api: format!("https://{}", pds),
 | 
			
		||||
            plc_api: "https://plc.directory".to_string(),
 | 
			
		||||
            bsky_api: "https://public.api.bsky.app".to_string(),
 | 
			
		||||
            web_url: "https://bsky.app".to_string(),
 | 
			
		||||
        },
 | 
			
		||||
        "syu.is" => NetworkConfig {
 | 
			
		||||
            pds_api: "https://syu.is".to_string(),
 | 
			
		||||
            plc_api: "https://plc.syu.is".to_string(),
 | 
			
		||||
            bsky_api: "https://bsky.syu.is".to_string(),
 | 
			
		||||
            web_url: "https://web.syu.is".to_string(),
 | 
			
		||||
        },
 | 
			
		||||
        _ => {
 | 
			
		||||
            // Default to Bluesky network for unknown PDS
 | 
			
		||||
            NetworkConfig {
 | 
			
		||||
                pds_api: format!("https://{}", pds),
 | 
			
		||||
                plc_api: "https://plc.directory".to_string(),
 | 
			
		||||
                bsky_api: "https://public.api.bsky.app".to_string(),
 | 
			
		||||
                web_url: "https://bsky.app".to_string(),
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
7. ユーザーの情報を取得、表示する
 | 
			
		||||
 | 
			
		||||
```sh
 | 
			
		||||
bsky_api=https://public.api.bsky.app
 | 
			
		||||
user_did=did:plc:uqzpqmrjnptsxezjx4xuh2mn
 | 
			
		||||
curl -sL "$bsky_api/xrpc/app.bsky.actor.getProfile?actor=$user_did"|jq -r .avatar
 | 
			
		||||
---
 | 
			
		||||
https://cdn.bsky.app/img/avatar/plain/did:plc:uqzpqmrjnptsxezjx4xuh2mn/bafkreid6kcc5pnn4b3ar7mj6vi3eiawhxgkcrw3edgbqeacyrlnlcoetea@jpeg
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
### AIの情報を表示する
 | 
			
		||||
 | 
			
		||||
AIが持つ`ai.syui.log.chat.lang`, `ai.syui.log.chat.comment`を表示します。
 | 
			
		||||
 | 
			
		||||
なお、これは通常、`VITE_ADMIN_HANDLE`にputRecordされます。そこから情報を読み込みます。`VITE_AI_HANDLE`はそのrecordの`author`のところに入ります。
 | 
			
		||||
 | 
			
		||||
```json
 | 
			
		||||
"author": {
 | 
			
		||||
  "did": "did:plc:4hqjfn7m6n5hno3doamuhgef",
 | 
			
		||||
  "avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:4hqjfn7m6n5hno3doamuhgef/bafkreiaxkv624mffw3cfyi67ufxtwuwsy2mjw2ygezsvtd44ycbgkfdo2a@jpeg",
 | 
			
		||||
  "handle": "yui.syui.ai",
 | 
			
		||||
  "displayName": "ai"
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
1. VITE_ADMIN_HANDLEから管理者のhandleを取得する。
 | 
			
		||||
2. VITE_ATPROTO_PDSから管理者のアカウントのpdsを取得する。
 | 
			
		||||
3. pdsからpdsApi, bskApi, plcApiを割り当てる。
 | 
			
		||||
 | 
			
		||||
```rust
 | 
			
		||||
    match pds {
 | 
			
		||||
        "bsky.social" | "bsky.app" => NetworkConfig {
 | 
			
		||||
            pds_api: format!("https://{}", pds),
 | 
			
		||||
            plc_api: "https://plc.directory".to_string(),
 | 
			
		||||
            bsky_api: "https://public.api.bsky.app".to_string(),
 | 
			
		||||
            web_url: "https://bsky.app".to_string(),
 | 
			
		||||
        },
 | 
			
		||||
        "syu.is" => NetworkConfig {
 | 
			
		||||
            pds_api: "https://syu.is".to_string(),
 | 
			
		||||
            plc_api: "https://plc.syu.is".to_string(),
 | 
			
		||||
            bsky_api: "https://bsky.syu.is".to_string(),
 | 
			
		||||
            web_url: "https://web.syu.is".to_string(),
 | 
			
		||||
        },
 | 
			
		||||
        _ => {
 | 
			
		||||
            // Default to Bluesky network for unknown PDS
 | 
			
		||||
            NetworkConfig {
 | 
			
		||||
                pds_api: format!("https://{}", pds),
 | 
			
		||||
                plc_api: "https://plc.directory".to_string(),
 | 
			
		||||
                bsky_api: "https://public.api.bsky.app".to_string(),
 | 
			
		||||
                web_url: "https://bsky.app".to_string(),
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
4. 管理者アカウントであるVITE_ADMIN_HANDLEとVITE_ATPROTO_PDSから`ai.syui.log.chat.lang`, `ai.syui.log.chat.comment`を取得する。
 | 
			
		||||
 | 
			
		||||
```sh
 | 
			
		||||
curl -sL "https://${VITE_ATPROTO_PDS}/xrpc/com.atproto.repo.listRecords?repo=${VITE_ADMIN_HANDLE}&collection=ai.syui.log.chat.comment" 
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
5. AIのprofileを取得する。
 | 
			
		||||
 | 
			
		||||
```sh
 | 
			
		||||
curl -sL "https://${VITE_ATPROTO_PDS}/xrpc/com.atproto.repo.describeRepo?repo=$VITE_AI_HANDLE" |jq -r ".didDoc.service.[].serviceEndpoint"
 | 
			
		||||
---
 | 
			
		||||
https://syu.is
 | 
			
		||||
 | 
			
		||||
curl -sL "https://${VITE_ATPROTO_PDS}/xrpc/com.atproto.repo.describeRepo?repo=$VITE_AI_HANDLE" |jq -r ".did"
 | 
			
		||||
did:plc:6qyecktefllvenje24fcxnie
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
6. pdsからpdsApi, bskApi, plcApiを割り当てる。
 | 
			
		||||
 | 
			
		||||
```rust
 | 
			
		||||
    match pds {
 | 
			
		||||
        "bsky.social" | "bsky.app" => NetworkConfig {
 | 
			
		||||
            pds_api: format!("https://{}", pds),
 | 
			
		||||
            plc_api: "https://plc.directory".to_string(),
 | 
			
		||||
            bsky_api: "https://public.api.bsky.app".to_string(),
 | 
			
		||||
            web_url: "https://bsky.app".to_string(),
 | 
			
		||||
        },
 | 
			
		||||
        "syu.is" => NetworkConfig {
 | 
			
		||||
            pds_api: "https://syu.is".to_string(),
 | 
			
		||||
            plc_api: "https://plc.syu.is".to_string(),
 | 
			
		||||
            bsky_api: "https://bsky.syu.is".to_string(),
 | 
			
		||||
            web_url: "https://web.syu.is".to_string(),
 | 
			
		||||
        },
 | 
			
		||||
        _ => {
 | 
			
		||||
            // Default to Bluesky network for unknown PDS
 | 
			
		||||
            NetworkConfig {
 | 
			
		||||
                pds_api: format!("https://{}", pds),
 | 
			
		||||
                plc_api: "https://plc.directory".to_string(),
 | 
			
		||||
                bsky_api: "https://public.api.bsky.app".to_string(),
 | 
			
		||||
                web_url: "https://bsky.app".to_string(),
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
7. AIの情報を取得、表示する
 | 
			
		||||
 | 
			
		||||
```sh
 | 
			
		||||
bsky_api=https://bsky.syu.is
 | 
			
		||||
user_did=did:plc:6qyecktefllvenje24fcxnie
 | 
			
		||||
curl -sL "$bsky_api/xrpc/app.bsky.actor.getProfile?actor=$user_did"|jq -r .avatar
 | 
			
		||||
---
 | 
			
		||||
https://bsky.syu.is/img/avatar/plain/did:plc:6qyecktefllvenje24fcxnie/bafkreiet4pwlnshk7igra5flf2fuxpg2bhvf2apts4rqwcr56hzhgycii4@jpeg
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
## 中核思想
 | 
			
		||||
- **存在子理論**: この世界で最も小さいもの(存在子/ai)の探求
 | 
			
		||||
- **唯一性原則**: 現実の個人の唯一性をすべてのシステムで担保
 | 
			
		||||
 
 | 
			
		||||
@@ -17,7 +17,7 @@ comment_moderation = false
 | 
			
		||||
ask_ai = true
 | 
			
		||||
provider = "ollama"
 | 
			
		||||
model = "gemma3:4b"
 | 
			
		||||
host = "https://ollama.syui.ai"
 | 
			
		||||
host = "https://localhost:11434"
 | 
			
		||||
system_prompt = "あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"
 | 
			
		||||
handle = "ai.syui.ai"
 | 
			
		||||
#num_predict = 200
 | 
			
		||||
 
 | 
			
		||||
@@ -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://ollama.syui.ai
 | 
			
		||||
VITE_AI_HOST=https://localhost:11434
 | 
			
		||||
VITE_AI_SYSTEM_PROMPT="あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"
 | 
			
		||||
 
 | 
			
		||||
@@ -118,6 +118,9 @@ function App() {
 | 
			
		||||
      
 | 
			
		||||
      // Load AI chat history (認証状態に関係なく、全ユーザーのチャット履歴を表示)
 | 
			
		||||
      loadAiChatHistory();
 | 
			
		||||
      
 | 
			
		||||
      // Load AI generated content (lang:en and AI comments)
 | 
			
		||||
      loadAIGeneratedContent();
 | 
			
		||||
    };
 | 
			
		||||
    
 | 
			
		||||
    // Wait for DID resolution before loading data
 | 
			
		||||
@@ -154,6 +157,7 @@ function App() {
 | 
			
		||||
        const config = getNetworkConfigFromPdsEndpoint(`https://${pds}`);
 | 
			
		||||
        const apiEndpoint = config.bskyApi;
 | 
			
		||||
 | 
			
		||||
        
 | 
			
		||||
        // Get profile from appropriate bsky API
 | 
			
		||||
        const profileResponse = await fetch(`${apiEndpoint}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(handle)}`);
 | 
			
		||||
        if (profileResponse.ok) {
 | 
			
		||||
@@ -290,6 +294,7 @@ function App() {
 | 
			
		||||
    if (adminDid && aiDid) {
 | 
			
		||||
      loadAllComments();
 | 
			
		||||
      loadAiChatHistory();
 | 
			
		||||
      loadAIGeneratedContent();
 | 
			
		||||
    }
 | 
			
		||||
  }, [adminDid, aiDid]);
 | 
			
		||||
 | 
			
		||||
@@ -331,18 +336,18 @@ function App() {
 | 
			
		||||
    try {
 | 
			
		||||
      // Load all chat records from users in admin's user list
 | 
			
		||||
      const currentAdminDid = adminDid || appConfig.adminDid;
 | 
			
		||||
      const atprotoApi = appConfig.atprotoApi || 'https://bsky.social';
 | 
			
		||||
      
 | 
			
		||||
      // Don't proceed if we don't have a valid DID
 | 
			
		||||
      if (!currentAdminDid) {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      // Use admin's PDS from config
 | 
			
		||||
      const adminConfig = getNetworkConfig(appConfig.atprotoPds);
 | 
			
		||||
      const collections = getCollectionNames(appConfig.collections.base);
 | 
			
		||||
      
 | 
			
		||||
      // First, get user list from admin using their proper PDS
 | 
			
		||||
      let adminPdsEndpoint;
 | 
			
		||||
      try {
 | 
			
		||||
        const resolved = await import('./utils/pds-detection').then(m => m.resolvePdsFromRepo(currentAdminDid));
 | 
			
		||||
        const config = await import('./utils/pds-detection').then(m => m.getNetworkConfigFromPdsEndpoint(resolved.pds));
 | 
			
		||||
        adminPdsEndpoint = config.pdsApi;
 | 
			
		||||
      } catch {
 | 
			
		||||
        adminPdsEndpoint = atprotoApi;
 | 
			
		||||
      }
 | 
			
		||||
      const adminPdsEndpoint = adminConfig.pdsApi;
 | 
			
		||||
      
 | 
			
		||||
      const userListResponse = await fetch(`${adminPdsEndpoint}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(currentAdminDid)}&collection=${encodeURIComponent(collections.user)}&limit=100`);
 | 
			
		||||
      
 | 
			
		||||
@@ -436,11 +441,19 @@ function App() {
 | 
			
		||||
  const loadAIGeneratedContent = async () => {
 | 
			
		||||
    try {
 | 
			
		||||
      const currentAdminDid = adminDid || appConfig.adminDid;
 | 
			
		||||
      const atprotoApi = appConfig.atprotoApi || 'https://bsky.social';
 | 
			
		||||
      
 | 
			
		||||
      // Don't proceed if we don't have a valid DID
 | 
			
		||||
      if (!currentAdminDid) {
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      // Use admin's PDS for collection access (from config)
 | 
			
		||||
      const adminConfig = getNetworkConfig(appConfig.atprotoPds);
 | 
			
		||||
      const atprotoApi = adminConfig.pdsApi;
 | 
			
		||||
      const collections = getCollectionNames(appConfig.collections.base);
 | 
			
		||||
      
 | 
			
		||||
      // Load lang:en records
 | 
			
		||||
      const langResponse = await fetch(`${atprotoApi}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(adminDid)}&collection=${encodeURIComponent(collections.chatLang)}&limit=100`);
 | 
			
		||||
      const langResponse = await fetch(`${atprotoApi}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(currentAdminDid)}&collection=${encodeURIComponent(collections.chatLang)}&limit=100`);
 | 
			
		||||
      if (langResponse.ok) {
 | 
			
		||||
        const langData = await langResponse.json();
 | 
			
		||||
        const langRecords = langData.records || [];
 | 
			
		||||
@@ -457,8 +470,14 @@ function App() {
 | 
			
		||||
        setLangEnRecords(filteredLangRecords);
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      // Load AI comment records
 | 
			
		||||
      const commentResponse = await fetch(`${atprotoApi}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(adminDid)}&collection=${encodeURIComponent(collections.chatComment)}&limit=100`);
 | 
			
		||||
      // Load AI comment records from admin account (not AI account)
 | 
			
		||||
      if (!currentAdminDid) {
 | 
			
		||||
        console.warn('No Admin DID available, skipping AI comment loading');
 | 
			
		||||
        setAiCommentRecords([]);
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      const commentResponse = await fetch(`${atprotoApi}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(currentAdminDid)}&collection=${encodeURIComponent(collections.chatComment)}&limit=100`);
 | 
			
		||||
      if (commentResponse.ok) {
 | 
			
		||||
        const commentData = await commentResponse.json();
 | 
			
		||||
        const commentRecords = commentData.records || [];
 | 
			
		||||
@@ -1092,14 +1111,23 @@ function App() {
 | 
			
		||||
 | 
			
		||||
  // ユーザーハンドルからプロフィールURLを生成
 | 
			
		||||
  const generateProfileUrl = (author: any): string => {
 | 
			
		||||
    // Use stored PDS info if available (from comment enhancement)
 | 
			
		||||
    if (author._webUrl) {
 | 
			
		||||
      return `${author._webUrl}/profile/${author.did}`;
 | 
			
		||||
    // Check if this is admin/AI handle that should use configured PDS
 | 
			
		||||
    if (author.handle === appConfig.adminHandle || author.handle === appConfig.aiHandle) {
 | 
			
		||||
      const config = getNetworkConfig(appConfig.atprotoPds);
 | 
			
		||||
      return `${config.webUrl}/profile/${author.did}`;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Fallback to handle-based detection
 | 
			
		||||
    // For ai.syu.is handle, also use configured PDS
 | 
			
		||||
    if (author.handle === 'ai.syu.is') {
 | 
			
		||||
      const config = getNetworkConfig(appConfig.atprotoPds);
 | 
			
		||||
      return `${config.webUrl}/profile/${author.did}`;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Get PDS from handle for other users
 | 
			
		||||
    const pds = detectPdsFromHandle(author.handle);
 | 
			
		||||
    const config = getNetworkConfigFromPdsEndpoint(`https://${pds}`);
 | 
			
		||||
    const config = getNetworkConfig(pds);
 | 
			
		||||
    
 | 
			
		||||
    // Use DID for profile URL
 | 
			
		||||
    return `${config.webUrl}/profile/${author.did}`;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
@@ -1135,7 +1163,8 @@ function App() {
 | 
			
		||||
    
 | 
			
		||||
    // Extract content based on format
 | 
			
		||||
    const contentText = isNewFormat ? value.text : (value.content || value.body || '');
 | 
			
		||||
    const authorInfo = isNewFormat ? value.author : null;
 | 
			
		||||
    // For AI comments, always use the loaded AI profile instead of record.value.author
 | 
			
		||||
    const authorInfo = aiProfile;
 | 
			
		||||
    const postInfo = isNewFormat ? value.post : null;
 | 
			
		||||
    const contentType = value.type || 'unknown';
 | 
			
		||||
    const createdAt = value.createdAt || value.generated_at || '';
 | 
			
		||||
@@ -1147,29 +1176,22 @@ function App() {
 | 
			
		||||
            src={authorInfo?.avatar || generatePlaceholderAvatar('AI')} 
 | 
			
		||||
            alt="AI Avatar" 
 | 
			
		||||
            className="comment-avatar"
 | 
			
		||||
            ref={(img) => {
 | 
			
		||||
              // For old format, try to fetch from ai_did
 | 
			
		||||
              if (img && !isNewFormat && value.ai_did) {
 | 
			
		||||
                fetch(`${appConfig.bskyPublicApi}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(value.ai_did)}`)
 | 
			
		||||
                  .then(res => res.json())
 | 
			
		||||
                  .then(data => {
 | 
			
		||||
                    if (data.avatar && img) {
 | 
			
		||||
                      img.src = data.avatar;
 | 
			
		||||
                    }
 | 
			
		||||
                  })
 | 
			
		||||
                  .catch(err => {
 | 
			
		||||
                    // Keep placeholder on error
 | 
			
		||||
                  });
 | 
			
		||||
              }
 | 
			
		||||
            }}
 | 
			
		||||
          />
 | 
			
		||||
          <div className="comment-author-info">
 | 
			
		||||
            <span className="comment-author">
 | 
			
		||||
              {authorInfo?.displayName || 'AI'}
 | 
			
		||||
            </span>
 | 
			
		||||
            <span className="comment-handle">
 | 
			
		||||
              @{authorInfo?.handle || aiProfile?.handle || 'yui.syui.ai'}
 | 
			
		||||
              {authorInfo?.displayName || 'ai'}
 | 
			
		||||
            </span>
 | 
			
		||||
            <a 
 | 
			
		||||
              href={generateProfileUrl({ 
 | 
			
		||||
                handle: authorInfo?.handle || aiProfile?.handle || appConfig.aiHandle, 
 | 
			
		||||
                did: authorInfo?.did || aiProfile?.did || appConfig.aiDid 
 | 
			
		||||
              })}
 | 
			
		||||
              target="_blank"
 | 
			
		||||
              rel="noopener noreferrer"
 | 
			
		||||
              className="comment-handle"
 | 
			
		||||
            >
 | 
			
		||||
              @{authorInfo?.handle || aiProfile?.handle || appConfig.aiHandle}
 | 
			
		||||
            </a>
 | 
			
		||||
          </div>
 | 
			
		||||
          <span className="comment-date">
 | 
			
		||||
            {new Date(createdAt).toLocaleString()}
 | 
			
		||||
@@ -1296,7 +1318,7 @@ function App() {
 | 
			
		||||
                      name="userList"
 | 
			
		||||
                      value={userListInput}
 | 
			
		||||
                      onChange={(e) => setUserListInput(e.target.value)}
 | 
			
		||||
                      placeholder="ユーザーハンドルをカンマ区切りで入力
例: syui.ai, yui.syui.ai, user.bsky.social"
 | 
			
		||||
                      placeholder="ユーザーハンドルをカンマ区切りで入力
例: syui.ai, ai.syui.ai, user.bsky.social"
 | 
			
		||||
                      rows={3}
 | 
			
		||||
                      disabled={isPostingUserList}
 | 
			
		||||
                    />
 | 
			
		||||
@@ -1493,93 +1515,9 @@ function App() {
 | 
			
		||||
              {aiChatHistory.length === 0 ? (
 | 
			
		||||
                <p className="no-chat">No AI conversations yet. Start chatting with Ask AI!</p>
 | 
			
		||||
              ) : (
 | 
			
		||||
                aiChatHistory.map((record, index) => {
 | 
			
		||||
                  // For AI responses, use AI DID; for user questions, use the actual author
 | 
			
		||||
                  const isAiResponse = record.value.type === 'answer';
 | 
			
		||||
                  const displayDid = isAiResponse ? (aiDid || appConfig.aiDid) : record.value.author?.did;
 | 
			
		||||
                  const displayHandle = isAiResponse ? (aiProfile?.handle || 'yui.syui.ai') : 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">
 | 
			
		||||
                        <img 
 | 
			
		||||
                          src={generatePlaceholderAvatar(displayHandle || 'unknown')} 
 | 
			
		||||
                          alt={isAiResponse ? "AI Avatar" : "User Avatar"} 
 | 
			
		||||
                          className="comment-avatar"
 | 
			
		||||
                          ref={(img) => {
 | 
			
		||||
                            // Fetch fresh avatar from API when component mounts
 | 
			
		||||
                            if (img && displayDid) {
 | 
			
		||||
                              fetch(`${appConfig.bskyPublicApi}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(displayDid)}`)
 | 
			
		||||
                                .then(res => res.json())
 | 
			
		||||
                                .then(data => {
 | 
			
		||||
                                  if (data.avatar && img) {
 | 
			
		||||
                                    img.src = data.avatar;
 | 
			
		||||
                                  }
 | 
			
		||||
                                })
 | 
			
		||||
                                .catch(err => {
 | 
			
		||||
                                  // Keep placeholder on error
 | 
			
		||||
                                });
 | 
			
		||||
                            }
 | 
			
		||||
                          }}
 | 
			
		||||
                        />
 | 
			
		||||
                        <div className="comment-author-info">
 | 
			
		||||
                          <span className="comment-author">
 | 
			
		||||
                            {displayName || 'unknown'}
 | 
			
		||||
                          </span>
 | 
			
		||||
                          <a 
 | 
			
		||||
                            href={generateProfileUrl({ handle: displayHandle, did: displayDid })}
 | 
			
		||||
                            target="_blank"
 | 
			
		||||
                            rel="noopener noreferrer"
 | 
			
		||||
                            className="comment-handle"
 | 
			
		||||
                          >
 | 
			
		||||
                            @{displayHandle || '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.type === 'question' ? 'Question' : 'Answer'}
 | 
			
		||||
                        </button>
 | 
			
		||||
                      </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                    
 | 
			
		||||
                    <div className="comment-meta">
 | 
			
		||||
                      {record.value.post?.url && (
 | 
			
		||||
                        <small><a href={record.value.post.url}>{record.value.post.url}</a></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 className="comment-content">
 | 
			
		||||
                      {record.value.text?.split('\n').map((line: string, index: number) => (
 | 
			
		||||
                        <React.Fragment key={index}>
 | 
			
		||||
                          {line}
 | 
			
		||||
                          {index < record.value.text.split('\n').length - 1 && <br />}
 | 
			
		||||
                        </React.Fragment>
 | 
			
		||||
                      ))}
 | 
			
		||||
                    </div>
 | 
			
		||||
                  </div>
 | 
			
		||||
                  );
 | 
			
		||||
                })
 | 
			
		||||
                aiChatHistory.map((record, index) => 
 | 
			
		||||
                  renderAIContent(record, index, 'comment-item')
 | 
			
		||||
                )
 | 
			
		||||
              )}
 | 
			
		||||
            </div>
 | 
			
		||||
          )}
 | 
			
		||||
@@ -1588,7 +1526,7 @@ function App() {
 | 
			
		||||
          {activeTab === 'lang-en' && (
 | 
			
		||||
            <div className="comments-list">
 | 
			
		||||
              {langEnRecords.length === 0 ? (
 | 
			
		||||
                <p className="no-content">No English translations yet</p>
 | 
			
		||||
                <p className="no-content">No EN translations yet</p>
 | 
			
		||||
              ) : (
 | 
			
		||||
                langEnRecords.map((record, index) => 
 | 
			
		||||
                  renderAIContent(record, index, 'lang-item')
 | 
			
		||||
@@ -1603,78 +1541,9 @@ function App() {
 | 
			
		||||
              {aiCommentRecords.length === 0 ? (
 | 
			
		||||
                <p className="no-content">No AI comments yet</p>
 | 
			
		||||
              ) : (
 | 
			
		||||
                aiCommentRecords.map((record, index) => (
 | 
			
		||||
                  <div key={index} className="comment-item">
 | 
			
		||||
                    <div className="comment-header">
 | 
			
		||||
                      <img 
 | 
			
		||||
                        src={generatePlaceholderAvatar('ai')} 
 | 
			
		||||
                        alt="AI Avatar" 
 | 
			
		||||
                        className="comment-avatar"
 | 
			
		||||
                        ref={(img) => {
 | 
			
		||||
                          // Fetch AI avatar
 | 
			
		||||
                          const currentAiDid = aiDid || appConfig.aiDid;
 | 
			
		||||
                          if (img && currentAiDid) {
 | 
			
		||||
                            fetch(`${appConfig.bskyPublicApi}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(currentAiDid)}`)
 | 
			
		||||
                              .then(res => res.json())
 | 
			
		||||
                              .then(data => {
 | 
			
		||||
                                if (data.avatar && img) {
 | 
			
		||||
                                  img.src = data.avatar;
 | 
			
		||||
                                }
 | 
			
		||||
                              })
 | 
			
		||||
                              .catch(err => {
 | 
			
		||||
                                // Keep placeholder on error
 | 
			
		||||
                              });
 | 
			
		||||
                          }
 | 
			
		||||
                        }}
 | 
			
		||||
                      />
 | 
			
		||||
                      <div className="comment-author-info">
 | 
			
		||||
                        <span className="comment-author">
 | 
			
		||||
                          AI
 | 
			
		||||
                        </span>
 | 
			
		||||
                        <span className="comment-handle">
 | 
			
		||||
                          @{aiProfile?.handle || 'yui.syui.ai'}
 | 
			
		||||
                        </span>
 | 
			
		||||
                      </div>
 | 
			
		||||
                      <span className="comment-date">
 | 
			
		||||
                        {new Date(record.value.createdAt || record.value.generated_at).toLocaleString()}
 | 
			
		||||
                      </span>
 | 
			
		||||
                      <div className="comment-actions">
 | 
			
		||||
                        <button 
 | 
			
		||||
                          onClick={() => toggleJsonDisplay(record.uri)}
 | 
			
		||||
                          className="json-button"
 | 
			
		||||
                          title="Show/Hide JSON"
 | 
			
		||||
                        >
 | 
			
		||||
                          {showJsonFor === record.uri ? 'Hide' : 'JSON'}
 | 
			
		||||
                        </button>
 | 
			
		||||
                      </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                    
 | 
			
		||||
                    <div className="comment-meta">
 | 
			
		||||
                      {(record.value.post?.url || record.value.post_url) && (
 | 
			
		||||
                        <small><a href={record.value.post?.url || record.value.post_url}>{record.value.post?.url || record.value.post_url}</a></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 className="comment-content">
 | 
			
		||||
                      {(record.value.text || record.value.comment)?.split('\n').map((line: string, index: number) => (
 | 
			
		||||
                        <React.Fragment key={index}>
 | 
			
		||||
                          {line}
 | 
			
		||||
                          {index < (record.value.text || record.value.comment)?.split('\n').length - 1 && <br />}
 | 
			
		||||
                        </React.Fragment>
 | 
			
		||||
                      ))}
 | 
			
		||||
                    </div>
 | 
			
		||||
                  </div>
 | 
			
		||||
                ))
 | 
			
		||||
                aiCommentRecords.map((record, index) => 
 | 
			
		||||
                  renderAIContent(record, index, 'comment-item')
 | 
			
		||||
                )
 | 
			
		||||
              )}
 | 
			
		||||
            </div>
 | 
			
		||||
          )}
 | 
			
		||||
 
 | 
			
		||||
@@ -89,7 +89,7 @@ export function getAppConfig(): AppConfig {
 | 
			
		||||
  
 | 
			
		||||
  // DIDsはハンドルから実行時に解決される(フォールバック用のみ保持)
 | 
			
		||||
  const adminDid = import.meta.env.VITE_ADMIN_DID || 'did:plc:uqzpqmrjnptsxezjx4xuh2mn';
 | 
			
		||||
  const aiDid = import.meta.env.VITE_AI_DID || 'did:plc:4hqjfn7m6n5hno3doamuhgef';
 | 
			
		||||
  const aiDid = import.meta.env.VITE_AI_DID || 'did:plc:6qyecktefllvenje24fcxnie';
 | 
			
		||||
  const aiDisplayName = import.meta.env.VITE_AI_DISPLAY_NAME || 'ai';
 | 
			
		||||
  const aiAvatar = import.meta.env.VITE_AI_AVATAR || '';
 | 
			
		||||
  const aiDescription = import.meta.env.VITE_AI_DESCRIPTION || '';
 | 
			
		||||
 
 | 
			
		||||
@@ -1441,13 +1441,36 @@ async fn generate_ai_content(content: &str, prompt_type: &str, ai_config: &AiCon
 | 
			
		||||
    
 | 
			
		||||
    // Fallback to remote host
 | 
			
		||||
    let remote_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
 | 
			
		||||
    let is_local = ai_config.ollama_host.contains("localhost") || 
 | 
			
		||||
                   ai_config.ollama_host.contains("127.0.0.1") ||
 | 
			
		||||
                   ai_config.ollama_host.contains("::1") ||
 | 
			
		||||
                   ai_config.ollama_host.contains("192.168.") ||  // 192.168.0.0/16
 | 
			
		||||
                   ai_config.ollama_host.contains("10.") ||       // 10.0.0.0/8 
 | 
			
		||||
                   (ai_config.ollama_host.contains("172.") && {   // 172.16.0.0/12
 | 
			
		||||
                       // Extract 172.x and check if x is 16-31
 | 
			
		||||
                       if let Some(start) = ai_config.ollama_host.find("172.") {
 | 
			
		||||
                           let after_172 = &ai_config.ollama_host[start + 4..];
 | 
			
		||||
                           if let Some(dot_pos) = after_172.find('.') {
 | 
			
		||||
                               if let Ok(second_octet) = after_172[..dot_pos].parse::<u8>() {
 | 
			
		||||
                                   second_octet >= 16 && second_octet <= 31
 | 
			
		||||
                               } else { false }
 | 
			
		||||
                           } else { false }
 | 
			
		||||
                       } else { false }
 | 
			
		||||
                   });
 | 
			
		||||
    
 | 
			
		||||
    let mut request_builder = client.post(&remote_url).json(&request);
 | 
			
		||||
    
 | 
			
		||||
    if !is_local {
 | 
			
		||||
        println!("{}", format!("🔗 Making request to: {} with Origin: {}", remote_url, ai_config.blog_host).blue());
 | 
			
		||||
    let response = client
 | 
			
		||||
        .post(&remote_url)
 | 
			
		||||
        .header("Origin", &ai_config.blog_host)
 | 
			
		||||
        .json(&request)
 | 
			
		||||
        .send()
 | 
			
		||||
        .await?;
 | 
			
		||||
        request_builder = request_builder.header("Origin", &ai_config.blog_host);
 | 
			
		||||
    } else {
 | 
			
		||||
        println!("{}", format!("🔗 Making request to local network: {}", remote_url).blue());
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    let response = request_builder.send().await?;
 | 
			
		||||
    
 | 
			
		||||
    if !response.status().is_success() {
 | 
			
		||||
        return Err(anyhow::anyhow!("Ollama API request failed: {}", response.status()));
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user