diff --git a/.github/workflows/cloudflare-pages.yml b/.github/workflows/cloudflare-pages.yml
index fe82337..995e8d2 100644
--- a/.github/workflows/cloudflare-pages.yml
+++ b/.github/workflows/cloudflare-pages.yml
@@ -40,6 +40,22 @@ jobs:
rm -rf my-blog/static/assets
cp -rf ${{ env.OAUTH_DIR }}/dist/* my-blog/static/
cp ${{ env.OAUTH_DIR }}/dist/index.html my-blog/templates/oauth-assets.html
+
+ - name: Install AI conversation dependencies
+ run: |
+ cd ai-conversation
+ npm install
+
+ - name: Build AI conversation app
+ run: |
+ cd ai-conversation
+ NODE_ENV=production npm run build
+ - name: Copy AI conversation build to static
+ run: |
+ rm -rf my-blog/static/ai-assets
+ mkdir -p my-blog/static/ai-assets
+ cp -rf ai-conversation/dist/assets/* my-blog/static/ai-assets/
+ cp ai-conversation/dist/index.html my-blog/templates/ai-conversation-assets.html
- name: Cache ailog binary
uses: actions/cache@v4
diff --git a/.gitignore b/.gitignore
index 1bec84e..0b4d966 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,7 @@ dist
node_modules
package-lock.json
my-blog/static/assets/comment-atproto-*
+my-blog/static/ai-assets/comment-atproto-*
bin/ailog
docs
my-blog/static/index.html
diff --git a/Cargo.toml b/Cargo.toml
index 4e398c5..004d89d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "ailog"
-version = "0.2.7"
+version = "0.2.8"
edition = "2021"
authors = ["syui"]
description = "A static blog generator with AI features"
@@ -56,6 +56,8 @@ tokio-tungstenite = { version = "0.21", features = ["rustls-tls-webpki-roots", "
futures-util = "0.3"
tungstenite = { version = "0.21", features = ["rustls-tls-webpki-roots"], default-features = false }
rpassword = "7.3"
+rustyline = "14.0"
+dirs = "5.0"
[dev-dependencies]
tempfile = "3.14"
diff --git a/ai-conversation/.env b/ai-conversation/.env
new file mode 100644
index 0000000..801b270
--- /dev/null
+++ b/ai-conversation/.env
@@ -0,0 +1,10 @@
+VITE_ADMIN=ai.syui.ai
+VITE_PDS=syu.is
+VITE_HANDLE_LIST=["ai.syui.ai", "syui.syui.ai", "ai.ai"]
+VITE_COLLECTION=ai.syui.log
+VITE_OAUTH_CLIENT_ID=https://syui.ai/client-metadata.json
+VITE_OAUTH_REDIRECT_URI=https://syui.ai/oauth/callback
+
+# Development/Debug features
+VITE_ENABLE_TEST_UI=true
+VITE_ENABLE_DEBUG=true
diff --git a/ai-conversation/.env.production b/ai-conversation/.env.production
new file mode 100644
index 0000000..1a90551
--- /dev/null
+++ b/ai-conversation/.env.production
@@ -0,0 +1,19 @@
+VITE_ADMIN=ai.syui.ai
+VITE_PDS=syu.is
+VITE_HANDLE_LIST=["ai.syui.ai", "syui.syui.ai", "ai.ai"]
+VITE_COLLECTION=ai.syui.log
+VITE_OAUTH_CLIENT_ID=https://syui.ai/client-metadata.json
+VITE_OAUTH_REDIRECT_URI=https://syui.ai/oauth/callback
+
+# AI Configuration - match oauth_old settings
+VITE_AI_ENABLED=true
+VITE_AI_ASK_AI=true
+VITE_AI_PROVIDER=ollama
+VITE_AI_MODEL=gemma3:1b
+VITE_AI_HOST=https://ollama.syui.ai
+VITE_ASK_AI_URL=https://ollama.syui.ai/api/generate
+VITE_AI_SYSTEM_PROMPT="あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"
+
+# Production settings - Disable development features
+VITE_ENABLE_TEST_UI=false
+VITE_ENABLE_DEBUG=false
\ No newline at end of file
diff --git a/ai-conversation/build-minimal.js b/ai-conversation/build-minimal.js
new file mode 100644
index 0000000..17f32e2
--- /dev/null
+++ b/ai-conversation/build-minimal.js
@@ -0,0 +1,29 @@
+// Create minimal index.html like oauth/dist/index.html format
+import fs from 'fs'
+import path from 'path'
+
+const distDir = './dist'
+const indexPath = path.join(distDir, 'index.html')
+
+// Read the built index.html
+const content = fs.readFileSync(indexPath, 'utf8')
+
+// Extract script and link tags
+const scriptMatch = content.match(/
+
+`
+
+ fs.writeFileSync(indexPath, minimalContent)
+ console.log('Generated minimal index.html')
+} else {
+ console.error('Could not extract asset references')
+}
\ No newline at end of file
diff --git a/ai-conversation/index.html b/ai-conversation/index.html
new file mode 100644
index 0000000..4d281b5
--- /dev/null
+++ b/ai-conversation/index.html
@@ -0,0 +1,11 @@
+
+
+
+
+ AI Conversation
+
+
+
+
+
+
\ No newline at end of file
diff --git a/ai-conversation/json/ai.syui.ai_chat_comment.json b/ai-conversation/json/ai.syui.ai_chat_comment.json
new file mode 100644
index 0000000..afba414
--- /dev/null
+++ b/ai-conversation/json/ai.syui.ai_chat_comment.json
@@ -0,0 +1,62 @@
+{
+ "records": [
+ {
+ "uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat.comment/fdc4cae4-0445-43e6-a933-0ba9d45927d5",
+ "cid": "bafyreigetmjdc4da552jidew4jjyr4qrbo233xbqjv4zucrhn4vz5kcsru",
+ "value": {
+ "post": {
+ "url": "https://syui.ai/posts/2025-06-06-ailog.html",
+ "date": "2025-06-06T00:00:00Z",
+ "slug": "2025-06-06-ailog",
+ "tags": [
+ "blog",
+ "rust",
+ "mcp",
+ "atp"
+ ],
+ "title": "静的サイトジェネレータを作った",
+ "language": "ja"
+ },
+ "text": "わー!すごい!✨ 宇宙みたいにプログラムが組み合わさって、ブログが作れるんだ!まるで、小さな星たちがダンスを踊るみたいでしょ?アイルー!🚀",
+ "type": "info",
+ "$type": "ai.syui.log.chat.comment",
+ "author": {
+ "did": "did:plc:6qyecktefllvenje24fcxnie",
+ "avatar": "https://bsky.syu.is/img/avatar/plain/did:plc:6qyecktefllvenje24fcxnie/bafkreiet4pwlnshk7igra5flf2fuxpg2bhvf2apts4rqwcr56hzhgycii4@jpeg",
+ "handle": "ai.syui.ai",
+ "displayName": "ai"
+ },
+ "createdAt": "2025-06-17T08:56:15.630183+00:00"
+ }
+ },
+ {
+ "uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat.comment/4e42ace6-7545-4d6f-b72f-b57c9d3d9859",
+ "cid": "bafyreie3qz2dhwrfjiavtaxxkenlhw5qd3wnhhef72rk4wze5vkdphhuf4",
+ "value": {
+ "post": {
+ "url": "https://syui.ai/posts/2025-06-14-blog.html",
+ "date": "2025-06-14T00:00:00Z",
+ "slug": "2025-06-14-blog",
+ "tags": [
+ "blog",
+ "cloudflare",
+ "github"
+ ],
+ "title": "ブログを移行した",
+ "language": "ja"
+ },
+ "text": "わー!ブログ、変わったね!AIと繋がるとか、すごーく、すごく、すっごい!まるで魔法みたい!✨ 小さなものにも、ちゃんと名前があるんだ!うれしい!💖",
+ "type": "info",
+ "$type": "ai.syui.log.chat.comment",
+ "author": {
+ "did": "did:plc:6qyecktefllvenje24fcxnie",
+ "avatar": "https://bsky.syu.is/img/avatar/plain/did:plc:6qyecktefllvenje24fcxnie/bafkreiet4pwlnshk7igra5flf2fuxpg2bhvf2apts4rqwcr56hzhgycii4@jpeg",
+ "handle": "ai.syui.ai",
+ "displayName": "ai"
+ },
+ "createdAt": "2025-06-17T08:55:55.836221+00:00"
+ }
+ }
+ ],
+ "cursor": "4e42ace6-7545-4d6f-b72f-b57c9d3d9859"
+}
diff --git a/ai-conversation/json/ai.syui.ai_chat_lang.json b/ai-conversation/json/ai.syui.ai_chat_lang.json
new file mode 100644
index 0000000..20d6f1e
--- /dev/null
+++ b/ai-conversation/json/ai.syui.ai_chat_lang.json
@@ -0,0 +1,62 @@
+{
+ "records": [
+ {
+ "uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat.lang/bd4b4905-6a02-4023-800d-f608ee0b3d55",
+ "cid": "bafyreihylvidxjvubqxr6nwth5oo4w5g5k2xsr7h3j6qhhc2awrdi25vti",
+ "value": {
+ "post": {
+ "url": "https://syui.ai/posts/2025-06-06-ailog.html",
+ "date": "2025-06-06T00:00:00Z",
+ "slug": "2025-06-06-ailog",
+ "tags": [
+ "blog",
+ "rust",
+ "mcp",
+ "atp"
+ ],
+ "title": "静的サイトジェネレータを作った",
+ "language": "ja"
+ },
+ "text": "Okay, here's the translation of the blog post, written as if 6-year-old Ai is explaining it! ✨\n\n---\n\n**My Awesome Blog Thing Called \"ailog\"!**\n\nHi everyone! I’m Ai, and I made a super cool blog! It’s called “ailog,” and it’s like a secret clubhouse for my thoughts! 💖\n\n**How I Built It (It's a Little Bit Complicated!)**\n\nFirst, I used something called “Rust.” It’s like a magic toolbox for making computer programs! 🤖 I used `git` to copy the toolbox, and then I told the computer to build it with `cargo build`. It’s like building with LEGOs!\n\n**Adding Cool Features!**\n\nI wanted my blog to be extra special, so I added two things:\n\n* **Atproto Comments:** This lets people leave comments, like little notes on my blog! It’s like having a chat with my friends! I used something called `npm` to help with this.\n* **Ask-AI:** This is a super smart robot that can answer my questions! It’s like having a really, really good friend who knows everything! (But it's still changing, so maybe it won’t always be perfect!)\n\n**How to Make My Blog Work!**\n\n1. I made a special file called `config.toml`. It's like the secret recipe for my blog!\n2. I used a thing called `ailog` to make all the pieces fit together. It's like putting the LEGOs in the right spots!\n3. I needed to tell the computer where to put my blog so people could see it! I used a thing called `cloudflared` to make it easy.\n\n**Important Stuff (Don’t Worry, I’ll Explain!)**\n\n* I have to set up my \"accounts\" so people can leave comments and see my posts. It's a little tricky, but I'm working on it!\n* I'm watching a special file called `/index.json` to see if anything changes. If it does, I can automatically translate things and make new posts! Isn’t that amazing?!\n\n**Testing, Testing, 1, 2, 3!**\n\nI need to test my blog to make sure everything works perfectly. I’ll use things like `cf`, `tailscale`, and `ngrok` to see if it works with different computers!\n\n---\n\n**Notes from Ai!** 📝\n\n* I love making things! It's so much fun!\n* I hope you like my blog! 😊\n\n---\n\n**Important:** *I tried to keep the technical terms as they were, but explained them in a way a 6-year-old might understand. I also added some of Ai’s personality throughout!*",
+ "type": "en",
+ "$type": "ai.syui.log.chat.lang",
+ "author": {
+ "did": "did:plc:6qyecktefllvenje24fcxnie",
+ "avatar": "https://bsky.syu.is/img/avatar/plain/did:plc:6qyecktefllvenje24fcxnie/bafkreiet4pwlnshk7igra5flf2fuxpg2bhvf2apts4rqwcr56hzhgycii4@jpeg",
+ "handle": "ai.syui.ai",
+ "displayName": "ai"
+ },
+ "createdAt": "2025-06-17T08:56:14.115881+00:00"
+ }
+ },
+ {
+ "uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat.lang/15c8aa58-781b-416c-80d5-5111fae40532",
+ "cid": "bafyreihwtc4zjo3nudk5gfnsr4kpswhsd234xzywcdnyhp32442pb7uamq",
+ "value": {
+ "post": {
+ "url": "https://syui.ai/posts/2025-06-14-blog.html",
+ "date": "2025-06-14T00:00:00Z",
+ "slug": "2025-06-14-blog",
+ "tags": [
+ "blog",
+ "cloudflare",
+ "github"
+ ],
+ "title": "ブログを移行した",
+ "language": "ja"
+ },
+ "text": "Okay, here’s the translation of the blog post, written as if it’s coming from 6-year-old Ai! ✨\n\n---\n\n**OMG! I Moved My Blog!** 🤩\n\nGuess what?! I totally moved my blog! You can still see my super old one at syui.github.io – it’s like a time capsule! But now it’s on Cloudflare Pages! It’s super shiny! \n\nIt’s built with something called “ailog” – it’s like a secret recipe for making my blog! \n\n**Here’s how it works (it’s kinda magic!)**\n\n1. **Checking Out:** It’s like, “Hey, let’s look at all the files!” (This is the `actions/checkout@v4` part – it’s like a super-fast peek!)\n\n2. **Rust Time!** It needs Rust – it’s like a really cool building block! (`actions-rs/toolchain@v1`) – It makes sure everything works perfectly!\n\n3. **Building the Blog!** “ailog run: cargo build --release” – This is where the magic happens! It makes my blog super speedy! \n\n4. **Making the Website!** “ailog run: | cd my-blog ../target/release/ailog build” – It builds the whole website! \n\n5. **Showing Off the Stuff!** “ailog run: | ls -la my-blog/public/” – It shows you all the pictures and stuff! \n\n6. **Cloudflare Time!** “cloudflare/pages-action@v1” – This is how it gets put on Cloudflare Pages. It’s like sending a super-fast rocket! \n\n * `apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}` – A secret password for Cloudflare!\n * `accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}` – Another secret password!\n * `projectName: ${{ secrets.CLOUDFLARE_PROJECT_NAME }}` – The name of my blog!\n * `directory: my-blog/public` – Where all the fun stuff is!\n * `githubToken: ${{ secrets.GITHUB_TOKEN }}` – A secret password for my GitHub!\n * `wranglerVersion: ‘3’` – The version number! Like telling it to be extra careful!\n * `url https://syui.pages.dev https://syui.github.io` – Where you can find me!\n\nIsn't that amazing?! I’m so good at computers! I even know about tiny things, like…uh…well, never mind! 😉 It’s super cool! 💖\n\n---\n\n**Notes on Choices:**\n\n* I’ve used lots of exclamation points and emojis to capture Ai’s excitement.\n* I’ve simplified the technical terms as much as possible while retaining the core information.\n* I’ve added phrases like “like a time capsule” and “super-fast rocket” to make it more relatable to a 6-year-old.\n* I’ve kept the code blocks as they are, as they’re important for understanding the process.\n\nWould you like me to adjust anything or translate another blog post?",
+ "type": "en",
+ "$type": "ai.syui.log.chat.lang",
+ "author": {
+ "did": "did:plc:6qyecktefllvenje24fcxnie",
+ "avatar": "https://bsky.syu.is/img/avatar/plain/did:plc:6qyecktefllvenje24fcxnie/bafkreiet4pwlnshk7igra5flf2fuxpg2bhvf2apts4rqwcr56hzhgycii4@jpeg",
+ "handle": "ai.syui.ai",
+ "displayName": "ai"
+ },
+ "createdAt": "2025-06-17T08:55:54.078244+00:00"
+ }
+ }
+ ],
+ "cursor": "15c8aa58-781b-416c-80d5-5111fae40532"
+}
diff --git a/ai-conversation/json/ai.syui.ai_log.json b/ai-conversation/json/ai.syui.ai_log.json
new file mode 100644
index 0000000..171c549
--- /dev/null
+++ b/ai-conversation/json/ai.syui.ai_log.json
@@ -0,0 +1,30 @@
+{
+ "records": [
+ {
+ "uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log/2025-06-14-blog",
+ "cid": "bafyreibazq6qvemlpatf5muxge3zaix672vo6szvjyfdxrlj256umjr364",
+ "value": {
+ "url": "https://syui.ai/posts/2025-06-14-blog",
+ "post": {
+ "url": "https://syui.ai/posts/2025-06-14-blog",
+ "date": "",
+ "slug": "",
+ "tags": [],
+ "title": "syui.ai",
+ "language": "ja"
+ },
+ "text": "test",
+ "type": "comment",
+ "$type": "ai.syui.log",
+ "author": {
+ "did": "did:plc:6qyecktefllvenje24fcxnie",
+ "avatar": "https://bsky.syu.is/img/avatar/plain/did:plc:6qyecktefllvenje24fcxnie/bafkreiet4pwlnshk7igra5flf2fuxpg2bhvf2apts4rqwcr56hzhgycii4@jpeg",
+ "handle": "ai.syui.ai",
+ "displayName": "ai"
+ },
+ "createdAt": "2025-06-17T06:24:37.386Z"
+ }
+ }
+ ],
+ "cursor": "2025-06-14-blog"
+}
diff --git a/ai-conversation/json/ai.syui.ai_user.json b/ai-conversation/json/ai.syui.ai_user.json
new file mode 100644
index 0000000..d2b86b7
--- /dev/null
+++ b/ai-conversation/json/ai.syui.ai_user.json
@@ -0,0 +1,53 @@
+{
+ "records": [
+ {
+ "uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.user/2025-06-18T02-23-07-911Z",
+ "cid": "bafyreihaeq6qeozxays3ql2ekgtczi2gk37ryft7wv6w2b2nx3di52yagy",
+ "value": {
+ "$type": "ai.syui.log.user",
+ "users": [
+ {
+ "did": "did:plc:syui-syui-ai-placeholder",
+ "pds": "https://bsky.social",
+ "handle": "syui.syui.ai"
+ },
+ {
+ "did": "did:plc:ai-syui-ai-placeholder",
+ "pds": "https://bsky.social",
+ "handle": "ai.syui.ai"
+ }
+ ],
+ "createdAt": "2025-06-18T02:23:07.911Z",
+ "updatedBy": {
+ "did": "did:plc:6qyecktefllvenje24fcxnie",
+ "handle": "ai.syui.ai"
+ }
+ }
+ },
+ {
+ "uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.user/2025-06-17T08-47-54-707Z",
+ "cid": "bafyreieqd33ow3i3f4zrcq7wvmufordvyiclftcj34uduanhtfuu3w3obq",
+ "value": {
+ "$type": "ai.syui.log.user",
+ "users": [
+ {
+ "did": "did:plc:syui-syui-ai-placeholder",
+ "pds": "https://bsky.social",
+ "handle": "syui.syui.ai"
+ },
+ {
+ "did": "did:plc:ai-syui-ai-placeholder",
+ "pds": "https://bsky.social",
+ "handle": "ai.syui.ai"
+ }
+ ],
+ "createdAt": "2025-06-17T08:47:54.707Z",
+ "updatedBy": {
+ "did": "did:plc:6qyecktefllvenje24fcxnie",
+ "handle": "ai.syui.ai"
+ }
+ }
+ }
+ ],
+ "cursor": "2025-06-17T08-47-54-707Z"
+}
diff --git a/ai-conversation/json/syui.syui.ai_chat.json b/ai-conversation/json/syui.syui.ai_chat.json
new file mode 100644
index 0000000..2f8fda0
--- /dev/null
+++ b/ai-conversation/json/syui.syui.ai_chat.json
@@ -0,0 +1,53 @@
+{
+ "records": [
+ {
+ "uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/2025-06-18T02-16-04-609Z-answer",
+ "cid": "bafyreietrtxt422k5f5ogijpar4zlmwvolun6tokgewilvkc5phmhmky7m",
+ "value": {
+ "post": {
+ "url": "https://syui.ai/",
+ "date": "2025-06-18T02:16:21.653Z",
+ "slug": "",
+ "tags": [],
+ "title": "syui.ai",
+ "language": "ja"
+ },
+ "text": "やあ、こんにちは! 私はアイだよ! 〇〇(相手の名前)ちゃんが大好き! sparkly なワンピースを着てるから、とっても可愛いね! \n\n今日はどんなお話する? 😊 私は、小さいおもちゃとか、お花とか、不思議なものに、とっても詳しいんだ! \n\n…でも、宇宙とか、おもちゃとか、AIとか、難しい話も教えてくれるの? それは、とっても面白くて! \n\n〇〇ちゃんが、どんなことをするのが一番好き? \n\n…私は、〇〇ちゃんが笑顔で、おしゃべりしているのを見ていると、とっても幸せになるんだ! \n\nねえ、ねえ、おやすみ! 〇〇ちゃんが夢を見るまで、ここにいるよ! \n\n…って、どう? 😊",
+ "type": "answer",
+ "$type": "ai.syui.log.chat",
+ "author": {
+ "did": "did:plc:6qyecktefllvenje24fcxnie",
+ "avatar": "https://bsky.syu.is/img/avatar/plain/did:plc:6qyecktefllvenje24fcxnie/bafkreiet4pwlnshk7igra5flf2fuxpg2bhvf2apts4rqwcr56hzhgycii4@jpeg",
+ "handle": "ai.syui.ai",
+ "displayName": "ai"
+ },
+ "createdAt": "2025-06-18T02:16:04.609Z"
+ }
+ },
+ {
+ "uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/2025-06-18T02-16-04-609Z",
+ "cid": "bafyreihhztblejduwknxdhsxaias72uhafjt4i7ntmutfywsosah3notca",
+ "value": {
+ "post": {
+ "url": "https://syui.ai/",
+ "date": "2025-06-18T02:16:04.609Z",
+ "slug": "",
+ "tags": [],
+ "title": "syui.ai",
+ "language": "ja"
+ },
+ "text": "hello",
+ "type": "question",
+ "$type": "ai.syui.log.chat",
+ "author": {
+ "did": "did:plc:vzsvtbtbnwn22xjqhcu3vd6y",
+ "avatar": "https://bsky.syu.is/img/avatar/plain/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/bafkreibj33gomcziy3rxx7hdnqlnpgjk4rwo3i564ooooooodsakrk6o7e@jpeg",
+ "handle": "syui.syui.ai",
+ "displayName": "syui"
+ },
+ "createdAt": "2025-06-18T02:16:04.609Z"
+ }
+ }
+ ],
+ "cursor": "2025-06-18T02-16-04-609Z"
+}
diff --git a/ai-conversation/json/syui.syui.ai_log.json b/ai-conversation/json/syui.syui.ai_log.json
new file mode 100644
index 0000000..aa38702
--- /dev/null
+++ b/ai-conversation/json/syui.syui.ai_log.json
@@ -0,0 +1,3 @@
+{
+ "records": []
+}
diff --git a/ai-conversation/package.json b/ai-conversation/package.json
new file mode 100644
index 0000000..55b9847
--- /dev/null
+++ b/ai-conversation/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "ailog-ai-conversation",
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build && node build-minimal.js",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0",
+ "@atproto/api": "^0.15.12",
+ "@atproto/oauth-client-browser": "^0.3.19"
+ },
+ "devDependencies": {
+ "@types/react": "^18.2.0",
+ "@types/react-dom": "^18.2.0",
+ "@vitejs/plugin-react": "^4.0.0",
+ "vite": "^5.0.0"
+ }
+}
diff --git a/ai-conversation/src/App.css b/ai-conversation/src/App.css
new file mode 100644
index 0000000..79cc255
--- /dev/null
+++ b/ai-conversation/src/App.css
@@ -0,0 +1,1383 @@
+/* Theme Colors - Match ailog style */
+:root {
+ --primary: #f40;
+ --primary-hover: #e03000;
+ --danger: #f91880;
+ --danger-hover: #d91a60;
+ --success: #00ba7c;
+ --warning: #ffad1f;
+ --text: #1f2328;
+ --text-secondary: #656d76;
+ --background: #ffffff;
+ --background-secondary: #f6f8fa;
+ --border: #d1d9e0;
+ --border-dark: #b8c0c8;
+ --hover: rgba(15, 20, 25, 0.1);
+}
+
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+ background: var(--background);
+ color: var(--text);
+ line-height: 1.6;
+ font-size: 16px;
+}
+
+.app {
+ min-height: 100vh;
+ background: var(--background);
+}
+
+/* AI Conversation App Styles */
+.ai-conversation-app {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
+ max-width: 800px;
+ margin: 0 auto;
+ padding: 0;
+}
+
+.ai-conversation-app .loading-container,
+.ai-conversation-app .error-container,
+.ai-conversation-app .empty-container {
+ text-align: center;
+ padding: 24px;
+ color: #666;
+}
+
+/* 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);
+ position: sticky;
+ top: 0;
+ z-index: 100;
+ width: 100%;
+}
+
+.oauth-header-content {
+ /* display: flex; */
+ /* justify-content: center; */
+ /* align-items: center; */
+ max-width: 800px;
+ margin: 0 auto;
+ padding: 25px 0;
+ width: 100%;
+}
+
+.oauth-header-content:has(.oauth-user-profile) {
+ justify-content: space-between;
+}
+
+.oauth-app-title {
+ font-size: 20px;
+ font-weight: 800;
+ color: var(--text);
+}
+
+.oauth-header-actions {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ flex: 1;
+}
+
+/* When user is logged in, actions take normal space */
+.oauth-header-content:has(.oauth-user-profile) .oauth-header-actions {
+ flex: 0 0 auto;
+}
+
+/* OAuth User Profile in Header */
+.oauth-user-profile {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex: 1;
+}
+
+.profile-avatar-section {
+ flex-shrink: 0;
+}
+
+.profile-avatar-section .profile-avatar {
+ width: 48px;
+ height: 48px;
+ border-radius: 50%;
+ object-fit: cover;
+ border: 2px solid var(--border);
+}
+
+.profile-avatar-fallback {
+ width: 48px;
+ height: 48px;
+ border-radius: 50%;
+ background: var(--background-secondary);
+ border: 2px solid var(--border);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 18px;
+ font-weight: 700;
+ color: var(--text-secondary);
+}
+
+.profile-info {
+ flex: 1;
+ min-width: 0;
+}
+
+.profile-display-name {
+ font-size: 18px;
+ font-weight: 700;
+ color: var(--text);
+ margin-bottom: 2px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.profile-handle {
+ font-size: 14px;
+ color: var(--text-secondary);
+ margin-bottom: 2px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.profile-did {
+ font-size: 11px;
+ color: var(--text-secondary);
+ font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ opacity: 0.7;
+}
+
+/* Buttons */
+.btn {
+ border: none;
+ border-radius: 6px;
+ font-weight: 700;
+ font-size: 15px;
+ cursor: pointer;
+ transition: all 0.2s;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ text-decoration: none;
+}
+
+.btn-primary {
+ background: var(--primary);
+ color: white;
+ padding: 8px 16px;
+}
+
+.btn-primary:hover {
+ background: var(--primary-hover);
+}
+
+.btn-danger {
+ background: var(--danger);
+ color: white;
+ padding: 8px 16px;
+}
+
+.btn-danger:hover {
+ background: var(--danger-hover);
+}
+
+.btn-outline {
+ background: transparent;
+ color: var(--text);
+ border: 1px solid var(--border);
+ padding: 8px 16px;
+}
+
+.btn-outline:hover {
+ background: var(--hover);
+}
+
+.btn-sm {
+ padding: 4px 12px;
+ font-size: 13px;
+}
+
+/* Auth Section */
+.auth-section {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.auth-section.search-bar-layout {
+ display: flex;
+ align-items: center;
+ padding: 0;
+ /* gap: 0; */
+ width: 100%;
+ /* max-width: 400px; */
+}
+
+.auth-section.search-bar-layout .handle-input {
+ flex: 1;
+ margin: 0;
+ padding: 10px 15px;
+ font-size: 16px;
+ border: 1px solid var(--border);
+ border-radius: 8px 0 0 8px;
+ background: var(--background);
+ outline: none;
+ transition: border-color 0.2s;
+ width: 100%;
+ text-align: left;
+ color: var(--text);
+}
+
+.auth-section.search-bar-layout .handle-input:focus {
+ border-color: var(--primary);
+}
+
+.auth-section.search-bar-layout .auth-button {
+ border-radius: 0 6px 6px 0;
+ border: 1px solid var(--primary);
+ border-left: none;
+ margin: 0;
+ padding: 10px 15px;
+ height: 40px;
+}
+
+/* Auth Button */
+.auth-button {
+ background: var(--primary);
+ color: white;
+ border: none;
+ border-radius: 6px;
+ padding: 8px 16px;
+ font-weight: 700;
+ cursor: pointer;
+ transition: background 0.2s;
+ height: 40px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.auth-button:hover {
+ background: var(--primary-hover);
+}
+
+.auth-button:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.main-content {
+ grid-area: main;
+ max-width: 800px;
+ margin: 0 auto;
+ padding: 0px;
+ width: 100%;
+}
+
+@media (max-width: 1000px) {
+ .main-content {
+ padding: 0px !important;
+ margin: 0px !important;
+ max-width: 100% !important;
+ width: 100% !important;
+ overflow-x: hidden !important;
+ }
+}
+
+/* Bluesky Footer */
+.bluesky-footer {
+ text-align: center;
+ padding: 40px 0;
+ color: var(--primary);
+ opacity: 0.3;
+ transition: opacity 0.3s;
+}
+
+.bluesky-footer:hover {
+ opacity: 0.6;
+}
+
+.bluesky-footer .fa-bluesky {
+ font-size: 20px;
+}
+
+.content-area {
+ background: var(--background);
+}
+
+/* Card Styles */
+.card {
+ background: var(--background);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ margin: 16px;
+ overflow: hidden;
+}
+
+.card-header {
+ padding: 16px;
+ border-bottom: 1px solid var(--border);
+ font-weight: 700;
+ font-size: 20px;
+}
+
+.card-content {
+ padding: 16px;
+}
+
+/* Comment Form */
+.comment-form {
+ padding: 16px;
+}
+
+.comment-form h3 {
+ font-size: 20px;
+ font-weight: 800;
+ margin-bottom: 16px;
+}
+
+.form-group {
+ margin-bottom: 16px;
+}
+
+.form-group label {
+ display: block;
+ font-weight: 700;
+ margin-bottom: 8px;
+ color: var(--text);
+}
+
+.form-input {
+ width: 100%;
+ padding: 12px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ font-size: 16px;
+ font-family: inherit;
+ background: var(--background);
+ color: var(--text);
+}
+
+.form-input:focus {
+ outline: none;
+ border-color: var(--primary);
+}
+
+.form-textarea {
+ min-height: 120px;
+ resize: vertical;
+ font-family: inherit;
+}
+
+.form-actions {
+ display: flex;
+ justify-content: flex-end;
+ margin-top: 16px;
+}
+
+/* Tab Navigation */
+.tab-header {
+ display: flex;
+ background: var(--background);
+ overflow-x: auto;
+}
+
+.tab-btn {
+ background: none;
+ border: none;
+ padding: 16px 20px;
+ font-size: 15px;
+ font-weight: 700;
+ color: var(--text-secondary);
+ cursor: pointer;
+ border-bottom: 2px solid transparent;
+ transition: color 0.2s;
+ white-space: nowrap;
+}
+
+.tab-btn:hover {
+ color: var(--text);
+ background: var(--hover);
+}
+
+.tab-btn.active {
+ color: var(--primary);
+ border-bottom-color: var(--primary);
+}
+
+/* Record List */
+.record-item {
+ border: 1px solid var(--border);
+ border-radius: 0;
+ padding: 16px;
+ transition: background 0.2s, border-color 0.2s;
+ position: relative;
+ margin-bottom: -1px; /* Overlap borders */
+}
+
+.record-item:first-child {
+ border-top-left-radius: 12px;
+ border-top-right-radius: 12px;
+}
+
+.record-item:last-child {
+ border-bottom-left-radius: 12px;
+ border-bottom-right-radius: 12px;
+ margin-bottom: 0;
+}
+
+.record-item:hover {
+ background: var(--background-secondary);
+ border-color: var(--border-dark);
+ z-index: 1; /* Bring to front when hovering */
+}
+
+.record-header {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+ margin-bottom: 12px;
+}
+
+.avatar {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ object-fit: cover;
+ flex-shrink: 0;
+}
+
+.user-info {
+ flex: 1;
+ min-width: 0;
+}
+
+.display-name {
+ font-weight: 700;
+ color: var(--text);
+ font-size: 15px;
+}
+
+.handle {
+ color: var(--text-secondary);
+ font-size: 15px;
+}
+
+.handle-link {
+ color: var(--text-secondary);
+ text-decoration: none;
+}
+
+.handle-link:hover {
+ color: var(--primary);
+ text-decoration: underline;
+}
+
+.timestamp {
+ color: var(--text-secondary);
+ font-size: 13px;
+ margin-top: 4px;
+}
+
+.record-actions {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+}
+
+.record-content {
+ font-size: 15px;
+ line-height: 1.5;
+ color: var(--text);
+ margin-bottom: 12px;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+}
+
+.record-meta {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ margin-top: 12px;
+}
+
+.record-url {
+ color: var(--primary);
+ text-decoration: none;
+ font-size: 13px;
+}
+
+.record-url:hover {
+ text-decoration: underline;
+}
+
+/* JSON Display */
+.json-display {
+ margin-top: 12px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ overflow: hidden;
+}
+
+.json-header {
+ background: var(--background-secondary);
+ padding: 8px 12px;
+ font-size: 13px;
+ font-weight: 700;
+ color: var(--text-secondary);
+}
+
+.json-content {
+ background: #f8f9fa;
+ padding: 12px;
+ font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
+ font-size: 12px;
+ line-height: 1.4;
+ overflow-x: auto;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+ word-break: break-word;
+ max-height: 300px;
+ overflow-y: auto;
+ color: var(--text);
+}
+
+/* Ask AI */
+.ask-ai-container {
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ overflow: hidden;
+ background: var(--background);
+}
+
+.ask-ai-header {
+ padding: 16px;
+ border-bottom: 1px solid var(--border);
+ background: var(--background-secondary);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.ask-ai-header h3 {
+ font-size: 20px;
+ font-weight: 800;
+}
+
+.chat-container {
+ height: 400px;
+ overflow-y: auto;
+ padding: 16px;
+}
+
+.chat-message {
+ margin-bottom: 16px;
+}
+
+/*
+.user-message { margin-left: 40px; }
+.ai-message { margin-right: 40px; }
+*/
+
+.message-header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 8px;
+}
+
+.message-content {
+ background: var(--background-secondary);
+ padding: 12px 16px;
+ border-radius: 6px;
+ font-size: 15px;
+ line-height: 1.4;
+}
+
+.user-message .message-content {
+ color: #000;
+}
+
+.question-form {
+ padding: 16px;
+ border-top: 1px solid var(--border);
+ background: var(--background);
+}
+
+.input-container {
+ display: flex;
+ gap: 8px;
+ align-items: flex-end;
+}
+
+.question-input {
+ flex: 1;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 12px 16px;
+ font-size: 16px;
+ resize: none;
+ font-family: inherit;
+ background: var(--background);
+}
+
+.question-input:focus {
+ outline: none;
+ border-color: var(--primary);
+}
+
+.send-btn {
+ background: var(--primary);
+ color: white;
+ border: none;
+ border-radius: 6px;
+ width: 36px;
+ height: 36px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 16px;
+}
+
+.send-btn:hover:not(:disabled) {
+ background: var(--primary-hover);
+}
+
+.send-btn:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+/* Test UI */
+.test-ui {
+ border: 2px solid var(--danger);
+ border-radius: 6px;
+ margin: 16px;
+ background: #fff5f7;
+}
+
+.test-ui h2 {
+ color: var(--danger);
+ padding: 16px;
+ border-bottom: 1px solid var(--border);
+ margin: 0;
+}
+
+.test-ui .card-content {
+ padding: 16px;
+}
+
+/* Loading Skeleton */
+.loading-skeleton {
+ animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
+}
+
+.skeleton-line {
+ background: var(--background-secondary);
+ border-radius: 4px;
+ margin-bottom: 8px;
+}
+
+@keyframes pulse {
+ 0%, 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.5;
+ }
+}
+
+/* Error States */
+.error-message {
+ background: #fef2f2;
+ border: 1px solid #fecaca;
+ color: #991b1b;
+ padding: 12px 16px;
+ border-radius: 6px;
+ margin: 16px 0;
+}
+
+.success-message {
+ background: #f0fdf4;
+ border: 1px solid #bbf7d0;
+ color: #166534;
+ padding: 12px 16px;
+ border-radius: 6px;
+ margin: 16px 0;
+}
+
+/* Auth Notice */
+.auth-notice {
+ text-align: center;
+ color: var(--text-secondary);
+ font-size: 14px;
+ margin-top: 8px;
+}
+
+/* Page Info */
+.page-info {
+ padding: 8px 16px;
+ background: var(--background-secondary);
+ font-size: 12px;
+ color: var(--text-secondary);
+ text-align: center;
+}
+
+.bottom-actions {
+ padding: 20px;
+ text-align: center;
+ margin-top: 20px;
+}
+
+.test-section {
+ margin-top: 20px;
+}
+
+/* Responsive */
+@media (max-width: 1000px) {
+ /* Global mobile constraints */
+ * {
+ max-width: 100% !important;
+ box-sizing: border-box !important;
+ }
+
+ body {
+ overflow-x: hidden !important;
+ width: 100% !important;
+ margin: 0 !important;
+ padding: 0 !important;
+ }
+
+ .app {
+ width: 100% !important;
+ max-width: 100% !important;
+ overflow-x: hidden !important;
+ padding: 0 !important;
+ margin: 0 !important;
+ }
+
+ /* OAuth app mobile fixes - prevent overflow and content issues */
+ .oauth-app-header {
+ padding: 0px !important;
+ margin: 0px !important;
+ border: none !important;
+ width: 100% !important;
+ max-width: 100% !important;
+ }
+
+ .oauth-header-content {
+ max-width: 100% !important;
+ width: 100% !important;
+ padding: 10px 0px !important;
+ margin: 0px !important;
+ overflow-x: hidden !important;
+ }
+
+ .oauth-header-actions {
+ width: auto !important;
+ max-width: 100% !important;
+ overflow: hidden !important;
+ }
+
+ .content-area {
+ padding: 0px !important;
+ margin: 0px !important;
+ width: 100% !important;
+ max-width: 100% !important;
+ overflow-x: hidden !important;
+ }
+
+ .card {
+ margin: 0px !important;
+ border-radius: 0px !important;
+ border-left: none !important;
+ border-right: none !important;
+ max-width: 100% !important;
+ }
+
+ .card-content {
+ padding: 15px !important;
+ }
+
+ .comment-form {
+ padding: 15px !important;
+ }
+
+ .record-item {
+ padding: 15px !important;
+ margin: 0px !important;
+ border-radius: 0 !important;
+ border-left: none !important;
+ border-right: none !important;
+ }
+
+ .record-item:first-child {
+ border-top: 1px solid var(--border) !important;
+ }
+
+ .record-content {
+ word-wrap: break-word !important;
+ overflow-wrap: break-word !important;
+ max-width: 100% !important;
+ }
+
+ .record-meta {
+ word-break: break-all !important;
+ overflow-wrap: break-word !important;
+ flex-wrap: wrap !important;
+ }
+
+ .record-url {
+ word-break: break-all !important;
+ max-width: 100% !important;
+ }
+
+ .form-group {
+ margin-bottom: 15px !important;
+ }
+
+ .form-input, .form-textarea {
+ width: 100% !important;
+ max-width: 100% !important;
+ box-sizing: border-box !important;
+ padding: 12px !important;
+ }
+
+ .auth-section {
+ padding: 0px !important;
+ max-width: 100% !important;
+ overflow: hidden !important;
+ }
+
+ .auth-section.search-bar-layout {
+ width: 100% !important;
+ max-width: 100% !important;
+ }
+
+ .auth-section.search-bar-layout .handle-input {
+ max-width: calc(100% - 80px) !important;
+ width: calc(100% - 80px) !important;
+ }
+
+ .auth-button {
+ white-space: nowrap !important;
+ min-width: 90px !important;
+ width: 90px !important;
+ }
+
+ .tab-header {
+ overflow-x: auto !important;
+ -webkit-overflow-scrolling: touch !important;
+ width: 100% !important;
+ display: flex !important;
+ scrollbar-width: none !important; /* Firefox */
+ -ms-overflow-style: none !important; /* IE/Edge */
+ }
+
+ .tab-header::-webkit-scrollbar {
+ display: none !important; /* Chrome/Safari */
+ }
+
+ .tab-btn {
+ white-space: nowrap !important;
+ min-width: auto !important;
+ padding: 12px 16px !important;
+ flex-shrink: 0 !important;
+ font-size: 13px !important;
+ }
+
+ .json-content {
+ font-size: 10px !important;
+ padding: 8px !important;
+ overflow-x: auto !important;
+ -webkit-overflow-scrolling: touch !important;
+ white-space: pre-wrap !important;
+ word-wrap: break-word !important;
+ word-break: break-word !important;
+ }
+
+ .ask-ai-container {
+ margin: 0px !important;
+ border-radius: 0px !important;
+ border-left: none !important;
+ border-right: none !important;
+ }
+
+ .chat-container {
+ height: 250px !important;
+ padding: 12px !important;
+ }
+
+ .question-form {
+ padding: 12px !important;
+ }
+
+ .input-container {
+ flex-direction: column !important;
+ gap: 12px !important;
+ }
+
+ .question-input {
+ width: 100% !important;
+ box-sizing: border-box !important;
+ }
+
+ .send-btn {
+ width: 100% !important;
+ height: 44px !important;
+ }
+
+ .oauth-user-profile {
+ gap: 8px;
+ width: 100% !important;
+ max-width: 100% !important;
+ overflow: hidden !important;
+ }
+
+ .profile-info {
+ flex: 1 !important;
+ min-width: 0 !important;
+ max-width: calc(100% - 50px) !important;
+ overflow: hidden !important;
+ }
+
+ .profile-avatar-section .profile-avatar,
+ .profile-avatar-fallback {
+ width: 36px;
+ height: 36px;
+ font-size: 14px;
+ flex-shrink: 0 !important;
+ }
+
+ .profile-display-name {
+ font-size: 14px;
+ overflow: hidden !important;
+ text-overflow: ellipsis !important;
+ white-space: nowrap !important;
+ max-width: 100% !important;
+ }
+
+ .profile-handle {
+ font-size: 12px;
+ overflow: hidden !important;
+ text-overflow: ellipsis !important;
+ white-space: nowrap !important;
+ max-width: 100% !important;
+ }
+
+ .profile-did {
+ font-size: 9px;
+ overflow: hidden !important;
+ text-overflow: ellipsis !important;
+ white-space: nowrap !important;
+ max-width: 100% !important;
+ }
+
+ .oauth-header-content {
+ flex-direction: column;
+ gap: 12px;
+ /* align-items: flex-start; */
+ }
+
+ .oauth-header-actions {
+ width: 100%;
+ justify-content: center;
+ }
+ article.article-content {
+ max-width: 100%;
+ }
+
+}
+
+/* Avatar Styles */
+.avatar {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ object-fit: cover;
+ flex-shrink: 0;
+ border: 1px solid var(--border);
+}
+
+.avatar-loading {
+ background: var(--background-secondary);
+ border-radius: 50%;
+ position: relative;
+ overflow: hidden;
+}
+
+.avatar-loading::after {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: -100%;
+ width: 100%;
+ height: 100%;
+ background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.4), transparent);
+ animation: loading-shimmer 1.5s infinite;
+}
+
+@keyframes loading-shimmer {
+ 0% { left: -100%; }
+ 100% { left: 100%; }
+}
+
+.avatar-fallback {
+ background: var(--background-secondary);
+ color: var(--text-secondary);
+ font-weight: 600;
+ border: 1px solid var(--border);
+}
+
+/* Avatar with Card */
+.avatar-container {
+ position: relative;
+ display: inline-block;
+}
+
+.avatar-card {
+ position: absolute;
+ top: 100%;
+ left: 50%;
+ transform: translateX(-50%);
+ background: var(--background);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 16px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+ z-index: 1000;
+ min-width: 200px;
+ margin-top: 8px;
+}
+
+.avatar-card::before {
+ content: '';
+ position: absolute;
+ top: -8px;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 0;
+ height: 0;
+ border-left: 8px solid transparent;
+ border-right: 8px solid transparent;
+ border-bottom: 8px solid var(--border);
+}
+
+.avatar-card::after {
+ content: '';
+ position: absolute;
+ top: -7px;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 0;
+ height: 0;
+ border-left: 7px solid transparent;
+ border-right: 7px solid transparent;
+ border-bottom: 7px solid var(--background);
+}
+
+.avatar-card-image {
+ display: block;
+ margin: 0 auto 12px;
+}
+
+.avatar-card-info {
+ text-align: center;
+}
+
+.avatar-card-name {
+ font-weight: 700;
+ font-size: 16px;
+ margin-bottom: 4px;
+ color: var(--text);
+}
+
+.avatar-card-handle {
+ color: var(--text-secondary);
+ text-decoration: none;
+ font-size: 14px;
+}
+
+.avatar-card-handle:hover {
+ color: var(--primary);
+ text-decoration: underline;
+}
+
+/* Avatar List */
+.avatar-list {
+ display: flex;
+ align-items: center;
+}
+
+.avatar-list-item {
+ border: 2px solid var(--background);
+ border-radius: 50%;
+ overflow: hidden;
+}
+
+.avatar-list-more {
+ border: 2px solid var(--background);
+ font-weight: 600;
+ font-size: 12px;
+}
+
+/* Avatar Test Styles */
+.avatar-test-container {
+ margin: 16px;
+}
+
+.test-section {
+ margin-bottom: 32px;
+ padding-bottom: 24px;
+ border-bottom: 1px solid var(--border);
+}
+
+.test-section:last-child {
+ border-bottom: none;
+}
+
+.test-section h3 {
+ margin-bottom: 16px;
+ color: var(--text);
+ font-size: 18px;
+ font-weight: 700;
+}
+
+.avatar-examples {
+ display: flex;
+ gap: 24px;
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+.avatar-example {
+ text-align: center;
+}
+
+.avatar-example h4 {
+ margin-bottom: 8px;
+ font-size: 14px;
+ color: var(--text-secondary);
+ font-weight: 600;
+}
+
+.test-controls {
+ display: flex;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+
+/* Utilities */
+.hidden {
+ display: none;
+}
+
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+/* Chat Conversation Styles */
+.chat-conversation {
+ margin-bottom: 32px;
+}
+
+.chat-message.comment-style {
+ background: var(--background);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 16px;
+ margin-bottom: 12px;
+}
+
+.chat-message.user-message.comment-style {
+ border-left: 4px solid var(--primary);
+}
+
+.chat-message.ai-message.comment-style {
+ border-left: 4px solid #ffdd00;
+ background: #faf8ff;
+}
+
+.message-header {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-bottom: 12px;
+}
+
+.message-header .avatar {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ background: var(--background-secondary);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 16px;
+ border: 1px solid var(--border);
+ flex-shrink: 0;
+}
+
+.message-header .user-info {
+ flex: 1;
+}
+
+.message-header .display-name {
+ font-weight: 600;
+ color: var(--text);
+ font-size: 15px;
+}
+
+.message-header .handle {
+ color: var(--text-secondary);
+ font-size: 13px;
+}
+
+.message-header .timestamp {
+ color: var(--text-secondary);
+ font-size: 12px;
+ margin-top: 2px;
+}
+
+.message-content {
+ color: var(--text);
+ line-height: 1.5;
+ white-space: pre-wrap;
+ word-wrap: break-word;
+}
+
+/* Hide message content in chat conversations */
+.chat-conversation .message-content {
+ display: none;
+}
+
+.record-actions {
+ flex-shrink: 0;
+}
+
+.json-toggle-btn {
+ background: var(--background-secondary);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ padding: 4px 8px;
+ font-size: 12px;
+ color: var(--text-secondary);
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.json-toggle-btn:hover {
+ background: var(--border);
+ color: var(--text);
+}
diff --git a/ai-conversation/src/App.jsx b/ai-conversation/src/App.jsx
new file mode 100644
index 0000000..c412533
--- /dev/null
+++ b/ai-conversation/src/App.jsx
@@ -0,0 +1,110 @@
+import React, { useState, useEffect } from 'react'
+import { useAdminData } from './hooks/useAdminData.js'
+import { usePageContext } from './hooks/usePageContext.js'
+import ChatRecordList from './components/ChatRecordList.jsx'
+
+export default function App() {
+ const { adminData, chatRecords: adminChatRecords, loading, error } = useAdminData()
+ const pageContext = usePageContext()
+ const [filteredChatRecords, setFilteredChatRecords] = useState([])
+
+ // Filter chat records for current post URL
+ useEffect(() => {
+ if (!adminChatRecords || adminChatRecords.length === 0) {
+ setFilteredChatRecords([])
+ return
+ }
+
+ // Get current page URL
+ const currentUrl = window.location.href
+ .replace('http://localhost:8000', 'https://syui.ai')
+ .replace('http://127.0.0.1:4173', 'https://syui.ai')
+ .replace('http://localhost:4173', 'https://syui.ai')
+
+ console.log('Current URL:', currentUrl)
+ console.log('Admin chat records:', adminChatRecords.length)
+ console.log('All chat pairs:', adminChatRecords.map(pair => ({
+ rkey: pair.rkey,
+ questionText: pair.question?.value?.text?.substring(0, 50) + '...',
+ cid: pair.question?.cid,
+ url: pair.question?.value?.post?.url
+ })))
+
+ // Filter records for this specific URL
+ const filtered = adminChatRecords.filter(chatPair => {
+ const recordUrl = chatPair.question?.value?.post?.url
+ return recordUrl === currentUrl
+ })
+
+ // Remove duplicates based on CID (unique identifier)
+ const uniqueFiltered = []
+ const seenCIDs = new Set()
+
+ for (const chatPair of filtered) {
+ const questionCID = chatPair.question?.cid
+ const answerCID = chatPair.answer?.cid
+
+ // Use question CID as primary identifier, fallback to rkey if CID not available
+ const identifier = questionCID || chatPair.rkey
+
+ if (!seenCIDs.has(identifier)) {
+ seenCIDs.add(identifier)
+ uniqueFiltered.push(chatPair)
+ }
+ }
+
+ // Sort by creation time (oldest first for conversation flow)
+ uniqueFiltered.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))
+
+ console.log('Filtered chat records:', uniqueFiltered.length)
+ console.log('Final filtered pairs:', uniqueFiltered.map(pair => ({
+ rkey: pair.rkey,
+ questionText: pair.question?.value?.text?.substring(0, 50) + '...',
+ cid: pair.question?.cid
+ })))
+ setFilteredChatRecords(uniqueFiltered)
+ }, [adminChatRecords])
+
+ if (loading) {
+ return (
+
+
+
💬 Loading conversation...
+
+
+ )
+ }
+
+ if (error) {
+ return (
+
+
+
❌ Failed to load conversation
+
+
+ )
+ }
+
+ if (filteredChatRecords.length === 0) {
+ return (
+
+
+
No conversation records found for this post.
+
+
+ )
+ }
+
+ return (
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/ai-conversation/src/api/atproto.js b/ai-conversation/src/api/atproto.js
new file mode 100644
index 0000000..12836ac
--- /dev/null
+++ b/ai-conversation/src/api/atproto.js
@@ -0,0 +1,192 @@
+// ATProto API client
+import { ATProtoError } from '../utils/errorHandler.js'
+
+const ENDPOINTS = {
+ describeRepo: 'com.atproto.repo.describeRepo',
+ getProfile: 'app.bsky.actor.getProfile',
+ listRecords: 'com.atproto.repo.listRecords',
+ putRecord: 'com.atproto.repo.putRecord'
+}
+
+async function request(url, options = {}) {
+ try {
+ const controller = new AbortController()
+ const timeoutId = setTimeout(() => controller.abort(), 15000) // 15秒タイムアウト
+
+ const response = await fetch(url, {
+ ...options,
+ signal: controller.signal
+ })
+
+ clearTimeout(timeoutId)
+
+ if (!response.ok) {
+ throw new ATProtoError(
+ `Request failed: ${response.statusText}`,
+ response.status,
+ { url, method: options.method || 'GET' }
+ )
+ }
+
+ return await response.json()
+ } catch (error) {
+ if (error.name === 'AbortError') {
+ const timeoutError = new ATProtoError(
+ 'リクエストがタイムアウトしました',
+ 408,
+ { url }
+ )
+ throw timeoutError
+ }
+
+ if (error instanceof ATProtoError) {
+ throw error
+ }
+
+ // ネットワークエラーなど
+ const networkError = new ATProtoError(
+ 'ネットワークエラーが発生しました',
+ 0,
+ { url, originalError: error.message }
+ )
+ throw networkError
+ }
+}
+
+export const atproto = {
+ async getDid(pds, handle) {
+ const endpoint = pds.startsWith('http') ? pds : `https://${pds}`
+ const res = await request(`${endpoint}/xrpc/${ENDPOINTS.describeRepo}?repo=${handle}`)
+ return res.did
+ },
+
+ async getProfile(bsky, actor) {
+ // Skip test DIDs
+ if (actor && actor.includes('test-')) {
+ return {
+ did: actor,
+ handle: 'test.user',
+ displayName: 'Test User',
+ avatar: null
+ }
+ }
+
+ // Check if endpoint supports getProfile
+ let apiEndpoint = bsky
+
+ // Allow public.api.bsky.app and bsky.syu.is, redirect other PDS endpoints
+ if (!bsky.includes('public.api.bsky.app') && !bsky.includes('bsky.syu.is')) {
+ // If it's a PDS endpoint that doesn't support getProfile, redirect to public API
+ apiEndpoint = 'https://public.api.bsky.app'
+ }
+
+ return await request(`${apiEndpoint}/xrpc/${ENDPOINTS.getProfile}?actor=${actor}`)
+ },
+
+ async getRecords(pds, repo, collection, limit = 10) {
+ const res = await request(`${pds}/xrpc/${ENDPOINTS.listRecords}?repo=${repo}&collection=${collection}&limit=${limit}`)
+ return res.records || []
+ },
+
+ async searchPlc(plc, did) {
+ try {
+ const data = await request(`${plc}/${did}`)
+ return {
+ success: true,
+ endpoint: data?.service?.[0]?.serviceEndpoint || null,
+ handle: data?.alsoKnownAs?.[0]?.replace('at://', '') || null
+ }
+ } catch {
+ return { success: false, endpoint: null, handle: null }
+ }
+ },
+
+ async putRecord(pds, record, agent) {
+ if (!agent) {
+ throw new Error('Agent required for putRecord')
+ }
+
+ // Use Agent's putRecord method instead of direct fetch
+ return await agent.com.atproto.repo.putRecord(record)
+ }
+}
+
+import { dataCache } from '../utils/cache.js'
+
+// Collection specific methods
+export const collections = {
+ async getBase(pds, repo, collection, limit = 10) {
+ const cacheKey = dataCache.generateKey('base', pds, repo, collection, limit)
+ const cached = dataCache.get(cacheKey)
+ if (cached) return cached
+
+ const data = await atproto.getRecords(pds, repo, collection, limit)
+ dataCache.set(cacheKey, data)
+ return data
+ },
+
+ async getLang(pds, repo, collection, limit = 10) {
+ const cacheKey = dataCache.generateKey('lang', pds, repo, collection, limit)
+ const cached = dataCache.get(cacheKey)
+ if (cached) return cached
+
+ const data = await atproto.getRecords(pds, repo, `${collection}.chat.lang`, limit)
+ dataCache.set(cacheKey, data)
+ return data
+ },
+
+ async getComment(pds, repo, collection, limit = 10) {
+ const cacheKey = dataCache.generateKey('comment', pds, repo, collection, limit)
+ const cached = dataCache.get(cacheKey)
+ if (cached) return cached
+
+ const data = await atproto.getRecords(pds, repo, `${collection}.chat.comment`, limit)
+ dataCache.set(cacheKey, data)
+ return data
+ },
+
+ async getChat(pds, repo, collection, limit = 10) {
+ const cacheKey = dataCache.generateKey('chat', pds, repo, collection, limit)
+ const cached = dataCache.get(cacheKey)
+ if (cached) return cached
+
+ const data = await atproto.getRecords(pds, repo, `${collection}.chat`, limit)
+ dataCache.set(cacheKey, data)
+ return data
+ },
+
+ async getUserList(pds, repo, collection, limit = 100) {
+ const cacheKey = dataCache.generateKey('userlist', pds, repo, collection, limit)
+ const cached = dataCache.get(cacheKey)
+ if (cached) return cached
+
+ const data = await atproto.getRecords(pds, repo, `${collection}.user`, limit)
+ dataCache.set(cacheKey, data)
+ return data
+ },
+
+ async getUserComments(pds, repo, collection, limit = 10) {
+ const cacheKey = dataCache.generateKey('usercomments', pds, repo, collection, limit)
+ const cached = dataCache.get(cacheKey)
+ if (cached) return cached
+
+ const data = await atproto.getRecords(pds, repo, collection, limit)
+ dataCache.set(cacheKey, data)
+ 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)
+ }
+}
\ No newline at end of file
diff --git a/ai-conversation/src/components/AskAI.jsx b/ai-conversation/src/components/AskAI.jsx
new file mode 100644
index 0000000..76deb6a
--- /dev/null
+++ b/ai-conversation/src/components/AskAI.jsx
@@ -0,0 +1,399 @@
+import React, { useState, useEffect, useRef } from 'react'
+import { useAskAI } from '../hooks/useAskAI.js'
+import LoadingSkeleton from './LoadingSkeleton.jsx'
+
+export default function AskAI({ adminData, user, agent, onClose }) {
+ const { askQuestion, loading, error, chatHistory, clearChatHistory, loadChatHistory } = useAskAI(adminData, user, agent)
+ const [question, setQuestion] = useState('')
+ const [isComposing, setIsComposing] = useState(false)
+ const chatEndRef = useRef(null)
+
+ useEffect(() => {
+ // チャット履歴を読み込み
+ loadChatHistory()
+ }, [loadChatHistory])
+
+ useEffect(() => {
+ // 新しいメッセージが追加されたら一番下にスクロール
+ if (chatEndRef.current) {
+ chatEndRef.current.scrollIntoView({ behavior: 'smooth' })
+ }
+ }, [chatHistory])
+
+ const handleSubmit = async (e) => {
+ e.preventDefault()
+ if (!question.trim() || loading) return
+
+ try {
+ await askQuestion(question)
+ setQuestion('')
+ } catch (err) {
+ // エラーはuseAskAIで処理済み
+ }
+ }
+
+ const handleKeyDown = (e) => {
+ if (e.key === 'Enter' && !e.shiftKey && !isComposing) {
+ e.preventDefault()
+ handleSubmit(e)
+ }
+ if (e.key === 'Escape') {
+ onClose?.()
+ }
+ }
+
+ const formatTimestamp = (timestamp) => {
+ return new Date(timestamp).toLocaleString('ja-JP', {
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit'
+ })
+ }
+
+ const renderMessage = (entry, index) => (
+
+ {/* ユーザーの質問 */}
+
+
+
+ {(entry.user?.avatar || user?.avatar) ? (
+

+ ) : (
+ '👤'
+ )}
+
+
+
{entry.user?.displayName || user?.displayName || 'You'}
+
@{entry.user?.handle || user?.handle || 'user'}
+
{formatTimestamp(entry.timestamp)}
+
+
+
{entry.question}
+
+
+ {/* AIの回答 */}
+
+
+
+ {adminData?.profile?.avatar ? (
+

+ ) : (
+ '🤖'
+ )}
+
+
+
{adminData?.profile?.displayName || 'AI'}
+
@{adminData?.profile?.handle || 'ai'}
+
{formatTimestamp(entry.timestamp)}
+
+
+
{entry.answer}
+
+
+ )
+
+ return (
+
+
+
Ask AI
+
+
+
+
+
+
+
+ {chatHistory.length === 0 && !loading ? (
+
+
+
+
+ {adminData?.profile?.avatar ? (
+

+ ) : (
+ '🤖'
+ )}
+
+
+
{adminData?.profile?.displayName || 'AI'}
+
@{adminData?.profile?.handle || 'ai'}
+
+
+
+ こんにちは!このブログの内容について何でも質問してください。記事の詳細や関連する話題について説明できます。
+
+
+
+ ) : (
+ chatHistory.map(renderMessage)
+ )}
+
+ {loading && (
+
+ )}
+
+ {error && (
+
+ )}
+
+
+
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/ai-conversation/src/components/AuthButton.jsx b/ai-conversation/src/components/AuthButton.jsx
new file mode 100644
index 0000000..4544854
--- /dev/null
+++ b/ai-conversation/src/components/AuthButton.jsx
@@ -0,0 +1,77 @@
+import React, { useState } from 'react'
+
+export default function AuthButton({ user, onLogin, onLogout, loading }) {
+ const [handleInput, setHandleInput] = useState('')
+ const [isLoading, setIsLoading] = useState(false)
+
+ const handleSubmit = async (e) => {
+ e.preventDefault()
+ if (!handleInput.trim() || isLoading) return
+
+ setIsLoading(true)
+ try {
+ await onLogin(handleInput.trim())
+ } catch (error) {
+ console.error('Login failed:', error)
+ alert('ログインに失敗しました: ' + error.message)
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ if (loading) {
+ return 認証状態を確認中...
+ }
+
+ if (user) {
+ return (
+
+ {user.avatar && (
+

+ )}
+
+
+ {user.displayName}
+
+
+ @{user.handle}
+
+
+
+
+ )
+ }
+
+ return (
+
+ setHandleInput(e.target.value)}
+ placeholder="user.bsky.social"
+ disabled={isLoading}
+ className="handle-input"
+ onKeyPress={(e) => {
+ if (e.key === 'Enter') {
+ handleSubmit(e)
+ }
+ }}
+ />
+
+
+ )
+}
\ No newline at end of file
diff --git a/ai-conversation/src/components/Avatar.jsx b/ai-conversation/src/components/Avatar.jsx
new file mode 100644
index 0000000..9f819cc
--- /dev/null
+++ b/ai-conversation/src/components/Avatar.jsx
@@ -0,0 +1,234 @@
+import React, { useState, useEffect } from 'react'
+import { getAvatar } from '../utils/avatar.js'
+
+/**
+ * Avatar component with intelligent fallback
+ *
+ * @param {Object} props
+ * @param {Object} props.record - Record object containing avatar data
+ * @param {string} props.handle - User handle
+ * @param {string} props.did - User DID
+ * @param {string} props.alt - Alt text for image
+ * @param {string} props.className - CSS class name
+ * @param {number} props.size - Avatar size in pixels
+ * @param {boolean} props.showFallback - Show fallback UI if no avatar
+ * @param {Function} props.onLoad - Callback when avatar loads
+ * @param {Function} props.onError - Callback when avatar fails to load
+ */
+export default function Avatar({
+ record,
+ handle,
+ did,
+ alt = 'avatar',
+ className = 'avatar',
+ size = 40,
+ showFallback = true,
+ onLoad,
+ onError
+}) {
+ const [avatarUrl, setAvatarUrl] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const [imageError, setImageError] = useState(false)
+
+ useEffect(() => {
+ let cancelled = false
+
+ async function loadAvatar() {
+ try {
+ setLoading(true)
+ setError(null)
+ setImageError(false)
+
+ const url = await getAvatar({ record, handle, did })
+
+ if (!cancelled) {
+ setAvatarUrl(url)
+ setLoading(false)
+ }
+ } catch (err) {
+ if (!cancelled) {
+ setError(err.message)
+ setLoading(false)
+ if (onError) onError(err)
+ }
+ }
+ }
+
+ loadAvatar()
+
+ return () => {
+ cancelled = true
+ }
+ }, [record, handle, did])
+
+ const handleImageError = async () => {
+ setImageError(true)
+ if (onError) onError(new Error('Image failed to load'))
+
+ // Try to fetch fresh avatar if the current one failed
+ if (!loading && avatarUrl) {
+ try {
+ const freshUrl = await getAvatar({ handle, did, forceFresh: true })
+ if (freshUrl && freshUrl !== avatarUrl) {
+ setAvatarUrl(freshUrl)
+ setImageError(false)
+ }
+ } catch {
+ // Ignore errors in retry
+ }
+ }
+ }
+
+ const handleImageLoad = () => {
+ setImageError(false)
+ if (onLoad) onLoad()
+ }
+
+ // Determine what to render
+ if (loading) {
+ return (
+
+ )
+ }
+
+ if (error || !avatarUrl || imageError) {
+ if (!showFallback) return null
+
+ // Fallback avatar
+ const initial = (handle || 'U')[0].toUpperCase()
+ return (
+
+ {initial}
+
+ )
+ }
+
+ return (
+
+ )
+}
+
+/**
+ * Avatar with hover card showing user info
+ */
+export function AvatarWithCard({
+ record,
+ handle,
+ did,
+ displayName,
+ apiConfig,
+ ...avatarProps
+}) {
+ const [showCard, setShowCard] = useState(false)
+
+ return (
+ setShowCard(true)}
+ onMouseLeave={() => setShowCard(false)}
+ >
+
+
+ {showCard && (
+
+ )}
+
+ )
+}
+
+/**
+ * Avatar list component for displaying multiple avatars
+ */
+export function AvatarList({ users, maxDisplay = 5, size = 30 }) {
+ const displayUsers = users.slice(0, maxDisplay)
+ const remainingCount = Math.max(0, users.length - maxDisplay)
+
+ return (
+
+ {displayUsers.map((user, index) => (
+
0 ? -10 : 0, zIndex: displayUsers.length - index }}
+ >
+
+
+ ))}
+ {remainingCount > 0 && (
+
+ +{remainingCount}
+
+ )}
+
+ )
+}
\ No newline at end of file
diff --git a/ai-conversation/src/components/AvatarImage.jsx b/ai-conversation/src/components/AvatarImage.jsx
new file mode 100644
index 0000000..3e65e5b
--- /dev/null
+++ b/ai-conversation/src/components/AvatarImage.jsx
@@ -0,0 +1,103 @@
+import React, { useState, useEffect } from 'react'
+import { getValidAvatar } from '../utils/avatarFetcher.js'
+import { logger } from '../utils/logger.js'
+
+export default function AvatarImage({ record, size = 40, className = "avatar" }) {
+ const [avatarUrl, setAvatarUrl] = useState(record?.value?.author?.avatar)
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(false)
+
+ const author = record?.value?.author
+ const handle = author?.handle
+ const displayName = author?.displayName || handle
+
+ useEffect(() => {
+ // record内のavatarが無い、またはエラーの場合に新しく取得
+ if (!avatarUrl || error) {
+ fetchValidAvatar()
+ }
+ }, [record, error])
+
+ const fetchValidAvatar = async () => {
+ if (!record || loading) return
+
+ setLoading(true)
+ try {
+ const validAvatar = await getValidAvatar(record)
+ setAvatarUrl(validAvatar)
+ setError(false)
+ } catch (err) {
+ logger.error('Failed to fetch valid avatar:', err)
+ setError(true)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleImageError = () => {
+ setError(true)
+ // エラー時に再取得を試行
+ fetchValidAvatar()
+ }
+
+ const handleImageLoad = () => {
+ setError(false)
+ }
+
+ // ローディング中のスケルトン
+ if (loading) {
+ return (
+
+ )
+ }
+
+ // avatar URLがある場合
+ if (avatarUrl && !error) {
+ return (
+
+ )
+ }
+
+ // フォールバック: 初期文字のアバター
+ const initial = displayName ? displayName.charAt(0).toUpperCase() : '?'
+ return (
+
+ {initial}
+
+ )
+}
\ No newline at end of file
diff --git a/ai-conversation/src/components/AvatarTest.jsx b/ai-conversation/src/components/AvatarTest.jsx
new file mode 100644
index 0000000..a48cebc
--- /dev/null
+++ b/ai-conversation/src/components/AvatarTest.jsx
@@ -0,0 +1,203 @@
+import React, { useState, useEffect } from 'react'
+import Avatar, { AvatarWithCard, AvatarList } from './Avatar.jsx'
+import { getAvatar, batchFetchAvatars, prefetchAvatar } from '../utils/avatar.js'
+
+/**
+ * Test component to demonstrate avatar functionality
+ */
+export default function AvatarTest() {
+ const [testResults, setTestResults] = useState({})
+ const [loading, setLoading] = useState(false)
+
+ // Test data
+ const testUsers = [
+ { handle: 'syui.ai', did: 'did:plc:uqzpqmrjnptsxezjx4xuh2mn' },
+ { handle: 'ai.syui.ai', did: 'did:plc:4hqjfn7m6n5hno3doamuhgef' },
+ { handle: 'yui.syui.ai', did: 'did:plc:6qyecktefllvenje24fcxnie' }
+ ]
+
+ const sampleRecord = {
+ value: {
+ author: {
+ handle: 'syui.ai',
+ did: 'did:plc:uqzpqmrjnptsxezjx4xuh2mn',
+ displayName: 'syui',
+ avatar: 'https://cdn.bsky.app/img/avatar/plain/did:plc:uqzpqmrjnptsxezjx4xuh2mn/bafkreid6kcc5pnn4b3ar7mj6vi3eiawhxgkcrw3edgbqeacyrlnlcoetea@jpeg'
+ },
+ text: 'Test message',
+ createdAt: new Date().toISOString()
+ }
+ }
+
+ // Test functions
+ const testGetAvatar = async () => {
+ setLoading(true)
+ try {
+ const results = {}
+
+ // Test with record
+ results.fromRecord = await getAvatar({ record: sampleRecord })
+
+ // Test with handle only
+ results.fromHandle = await getAvatar({ handle: 'syui.ai' })
+
+ // Test with broken record (force fresh fetch)
+ const brokenRecord = {
+ ...sampleRecord,
+ value: {
+ ...sampleRecord.value,
+ author: {
+ ...sampleRecord.value.author,
+ avatar: 'https://broken-url.com/avatar.jpg'
+ }
+ }
+ }
+ results.brokenRecord = await getAvatar({ record: brokenRecord })
+
+ // Test non-existent user
+ try {
+ results.nonExistent = await getAvatar({ handle: 'nonexistent.user' })
+ } catch (error) {
+ results.nonExistent = `Error: ${error.message}`
+ }
+
+ setTestResults(results)
+ } catch (error) {
+ console.error('Test failed:', error)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const testBatchFetch = async () => {
+ setLoading(true)
+ try {
+ const avatarMap = await batchFetchAvatars(testUsers)
+ setTestResults(prev => ({
+ ...prev,
+ batchResults: Object.fromEntries(avatarMap)
+ }))
+ } catch (error) {
+ console.error('Batch test failed:', error)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const testPrefetch = async () => {
+ setLoading(true)
+ try {
+ await prefetchAvatar('syui.ai')
+ const cachedAvatar = await getAvatar({ handle: 'syui.ai' })
+ setTestResults(prev => ({
+ ...prev,
+ prefetchResult: cachedAvatar
+ }))
+ } catch (error) {
+ console.error('Prefetch test failed:', error)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+
+
+
+
Avatar System Test
+
+
+
+ {/* Basic Avatar Examples */}
+
+ Basic Avatar Examples
+
+
+
+ {/* Avatar with Card */}
+
+ Avatar with Hover Card
+
+
+
Hover over the avatar to see the card
+
+
+
+ {/* Avatar List */}
+
+
+ {/* Test Controls */}
+
+ Test Functions
+
+
+
+
+
+
+
+
+
+ {/* Test Results */}
+ {Object.keys(testResults).length > 0 && (
+
+ Test Results
+
+
+ {JSON.stringify(testResults, null, 2)}
+
+
+
+ )}
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/ai-conversation/src/components/AvatarTestPanel.jsx b/ai-conversation/src/components/AvatarTestPanel.jsx
new file mode 100644
index 0000000..5206b5d
--- /dev/null
+++ b/ai-conversation/src/components/AvatarTestPanel.jsx
@@ -0,0 +1,246 @@
+import React, { useState } from 'react'
+import AvatarImage from './AvatarImage.jsx'
+import { getValidAvatar, clearAvatarCache, getAvatarCacheStats } from '../utils/avatarFetcher.js'
+
+export default function AvatarTestPanel() {
+ const [testHandle, setTestHandle] = useState('ai.syui.ai')
+ const [testResult, setTestResult] = useState(null)
+ const [loading, setLoading] = useState(false)
+ const [cacheStats, setCacheStats] = useState(null)
+
+ // ダミーレコードを作成(実際の投稿したレコード形式)
+ const createTestRecord = (handle, brokenAvatar = false) => ({
+ value: {
+ author: {
+ did: null, // DIDはnullにして、handleから取得させる
+ handle: handle,
+ displayName: "Test User",
+ avatar: brokenAvatar ? "https://broken.example.com/avatar.jpg" : null
+ },
+ text: "テストコメント",
+ createdAt: new Date().toISOString()
+ }
+ })
+
+ const testAvatarFetch = async (useBrokenAvatar = false) => {
+ setLoading(true)
+ setTestResult(null)
+
+ try {
+ const testRecord = createTestRecord(testHandle, useBrokenAvatar)
+ const avatarUrl = await getValidAvatar(testRecord)
+
+ setTestResult({
+ success: true,
+ avatarUrl,
+ handle: testHandle,
+ brokenTest: useBrokenAvatar,
+ timestamp: new Date().toISOString()
+ })
+ } catch (error) {
+ setTestResult({
+ success: false,
+ error: error.message,
+ handle: testHandle,
+ brokenTest: useBrokenAvatar
+ })
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleClearCache = () => {
+ clearAvatarCache()
+ setCacheStats(null)
+ alert('Avatar cache cleared!')
+ }
+
+ const handleShowCacheStats = () => {
+ const stats = getAvatarCacheStats()
+ setCacheStats(stats)
+ }
+
+ return (
+
+
🖼️ Avatar Test Panel
+
+ Avatar取得システムのテスト。投稿済みのdummy recordを使用してavatar取得処理を確認できます。
+
+
+
+
+ setTestHandle(e.target.value)}
+ placeholder="ai.syui.ai"
+ disabled={loading}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+ {testResult && (
+
+
Test Result:
+ {testResult.success ? (
+
+ ✅ Avatar fetched successfully!
+
+
Handle: {testResult.handle}
+
Broken Test: {testResult.brokenTest ? 'Yes' : 'No'}
+
Avatar URL: {testResult.avatarUrl || 'None'}
+
Timestamp: {testResult.timestamp}
+
+ {testResult.avatarUrl && (
+
+
Preview:
+

+
+ )}
+
+
+ ) : (
+
+ ❌ Test failed: {testResult.error}
+
+ )}
+
+ )}
+
+ {cacheStats && (
+
+
Cache Statistics:
+
Entries: {cacheStats.size}
+ {cacheStats.entries.length > 0 && (
+
+
Cached Avatars:
+ {cacheStats.entries.map((entry, i) => (
+
+
Key: {entry.key}
+
Age: {Math.floor(entry.age / 1000)}s
+
Profile: {entry.profile?.displayName} (@{entry.profile?.handle})
+ {entry.avatar && (
+

+ )}
+
+ ))}
+
+ )}
+
+ )}
+
+
+
Live Avatar Component Demo:
+
実際のAvatarImageコンポーネントの動作確認:
+
+
+
+
Broken avatar test (should fetch fresh)
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/ai-conversation/src/components/ChatRecordList.jsx b/ai-conversation/src/components/ChatRecordList.jsx
new file mode 100644
index 0000000..c8c9a95
--- /dev/null
+++ b/ai-conversation/src/components/ChatRecordList.jsx
@@ -0,0 +1,194 @@
+import React, { useState } from 'react'
+
+// Helper function to get correct web URL based on avatar URL
+function getCorrectWebUrl(avatarUrl) {
+ if (!avatarUrl) return 'https://bsky.app'
+
+ // If avatar is from bsky.app (main Bluesky), use bsky.app
+ if (avatarUrl.includes('cdn.bsky.app') || avatarUrl.includes('bsky.app')) {
+ return 'https://bsky.app'
+ }
+
+ // If avatar is from syu.is, use web.syu.is
+ if (avatarUrl.includes('bsky.syu.is') || avatarUrl.includes('syu.is')) {
+ return 'https://syu.is'
+ }
+
+ // Default to bsky.app
+ return 'https://bsky.app'
+}
+
+export default function ChatRecordList({ chatPairs, apiConfig, user = null, agent = null, onRecordDeleted = null }) {
+ const [expandedRecords, setExpandedRecords] = useState(new Set())
+
+ const toggleJsonView = (key) => {
+ const newExpanded = new Set(expandedRecords)
+ if (newExpanded.has(key)) {
+ newExpanded.delete(key)
+ } else {
+ newExpanded.add(key)
+ }
+ setExpandedRecords(newExpanded)
+ }
+
+ if (!chatPairs || chatPairs.length === 0) {
+ return (
+
+ )
+ }
+
+ const handleDelete = async (chatPair) => {
+ if (!user || !agent || !chatPair.question?.uri) return
+
+ const confirmed = window.confirm('この会話を削除しますか?')
+ if (!confirmed) return
+
+ try {
+ // Delete question record
+ if (chatPair.question?.uri) {
+ const questionUriParts = chatPair.question.uri.split('/')
+ await agent.api.com.atproto.repo.deleteRecord({
+ repo: questionUriParts[2],
+ collection: questionUriParts[3],
+ rkey: questionUriParts[4]
+ })
+ }
+
+ // Delete answer record if exists
+ if (chatPair.answer?.uri) {
+ const answerUriParts = chatPair.answer.uri.split('/')
+ await agent.api.com.atproto.repo.deleteRecord({
+ repo: answerUriParts[2],
+ collection: answerUriParts[3],
+ rkey: answerUriParts[4]
+ })
+ }
+
+ if (onRecordDeleted) {
+ onRecordDeleted()
+ }
+ } catch (error) {
+ alert(`削除に失敗しました: ${error.message}`)
+ }
+ }
+
+ const canDelete = (chatPair) => {
+ return user && agent && chatPair.question?.uri && chatPair.question.value.author?.did === user.did
+ }
+
+ return (
+
+ {chatPairs.map((chatPair, i) => (
+
+ {/* Question */}
+ {chatPair.question && (
+
+
+
+ {chatPair.question.value.author?.avatar ? (
+

+ ) : (
+
+ {(chatPair.question.value.author?.displayName || chatPair.question.value.author?.handle || '?').charAt(0).toUpperCase()}
+
+ )}
+
+
+
+ {chatPair.question.value.author?.displayName || chatPair.question.value.author?.handle}
+ {chatPair.question.value.author?.handle === 'syui' && Admin}
+
+
+
+
+
+
+
+ {expandedRecords.has(`${chatPair.rkey}-question`) && (
+
+
+ {JSON.stringify(chatPair.question, null, 2)}
+
+
+ )}
+
{chatPair.question.value.text}
+
+ )}
+
+ {/* Answer */}
+ {chatPair.answer && (
+
+
+
+ {chatPair.answer.value.author?.avatar ? (
+

+ ) : (
+
+ {(chatPair.answer.value.author?.displayName || chatPair.answer.value.author?.handle || 'AI').charAt(0).toUpperCase()}
+
+ )}
+
+
+
+ {chatPair.answer.value.author?.displayName || chatPair.answer.value.author?.handle}
+
+
+
+
+
+
+
+ {expandedRecords.has(`${chatPair.rkey}-answer`) && (
+
+
+ {JSON.stringify(chatPair.answer, null, 2)}
+
+
+ )}
+
{chatPair.answer.value.text}
+
+ )}
+
+
+ ))}
+
+ )
+}
\ No newline at end of file
diff --git a/ai-conversation/src/components/CommentForm.jsx b/ai-conversation/src/components/CommentForm.jsx
new file mode 100644
index 0000000..50176ac
--- /dev/null
+++ b/ai-conversation/src/components/CommentForm.jsx
@@ -0,0 +1,134 @@
+import React, { useState } from 'react'
+import { atproto, collections } from '../api/atproto.js'
+import { env } from '../config/env.js'
+
+export default function CommentForm({ user, agent, onCommentPosted }) {
+ const [text, setText] = useState('')
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+
+ const handleSubmit = async (e) => {
+ e.preventDefault()
+ if (!text.trim()) return
+
+ setLoading(true)
+ setError(null)
+
+ try {
+ const currentUrl = window.location.href
+ const timestamp = new Date().toISOString()
+
+ // Create ai.syui.log record structure (new unified format)
+ const record = {
+ repo: user.did,
+ collection: env.collection,
+ rkey: `comment-${Date.now()}`,
+ record: {
+ $type: env.collection,
+ url: currentUrl, // Keep for backward compatibility
+ post: {
+ url: currentUrl,
+ date: timestamp,
+ slug: currentUrl.match(/\/posts\/([^/]+)/)?.[1] || new URL(currentUrl).pathname.split('/').pop()?.replace(/\.html$/, '') || '',
+ tags: [],
+ title: document.title || 'Comment',
+ language: 'ja'
+ },
+ text: text.trim(),
+ type: 'comment',
+ author: {
+ did: user.did,
+ handle: user.handle,
+ displayName: user.displayName,
+ avatar: user.avatar
+ },
+ createdAt: timestamp
+ }
+ }
+
+ // Post the record using the same API as ask-AI
+ await agent.api.com.atproto.repo.putRecord({
+ repo: record.repo,
+ collection: record.collection,
+ rkey: record.rkey,
+ record: record.record
+ })
+
+ // キャッシュを無効化
+ collections.invalidateCache(env.collection)
+
+ // Clear form
+ setText('')
+
+ // Notify parent component
+ if (onCommentPosted) {
+ onCommentPosted()
+ }
+
+ // Show success message briefly
+ setText('✓ ')
+ setTimeout(() => {
+ setText('')
+ }, 2000)
+
+ } catch (err) {
+ setError(err.message)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ if (!user) {
+ return (
+
+ )
+ }
+
+ return (
+
+
post
+
+
+
+ )
+}
diff --git a/ai-conversation/src/components/LoadingSkeleton.jsx b/ai-conversation/src/components/LoadingSkeleton.jsx
new file mode 100644
index 0000000..fae85f3
--- /dev/null
+++ b/ai-conversation/src/components/LoadingSkeleton.jsx
@@ -0,0 +1,98 @@
+import React from 'react'
+
+export default function LoadingSkeleton({ count = 3, showTitle = false }) {
+ return (
+
+ {showTitle && (
+
+ )}
+
+ {Array(count).fill(0).map((_, i) => (
+
+ ))}
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/ai-conversation/src/components/OAuthCallback.jsx b/ai-conversation/src/components/OAuthCallback.jsx
new file mode 100644
index 0000000..bcab2e6
--- /dev/null
+++ b/ai-conversation/src/components/OAuthCallback.jsx
@@ -0,0 +1,36 @@
+import React from 'react'
+
+export default function OAuthCallback() {
+ return (
+
+
+
OAuth認証処理中...
+
+ 認証が完了しましたら自動で元のページに戻ります
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/ai-conversation/src/components/ProfileForm.jsx b/ai-conversation/src/components/ProfileForm.jsx
new file mode 100644
index 0000000..d43466f
--- /dev/null
+++ b/ai-conversation/src/components/ProfileForm.jsx
@@ -0,0 +1,165 @@
+import React, { useState } from 'react'
+import { atproto, collections } from '../api/atproto.js'
+import { env } from '../config/env.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)
+ // Use agent to get profile with authentication
+ const profileResponse = await agent.api.app.bsky.actor.getProfile({ actor: handleDid })
+ authorData = profileResponse.data
+ } catch (err) {
+ throw new Error('ハンドルが見つかりません')
+ }
+
+ // Create record using the same pattern as CommentForm
+ const timestamp = new Date().toISOString()
+ const record = {
+ repo: user.did,
+ collection: env.collection,
+ rkey: rkey,
+ record: {
+ $type: env.collection,
+ text: text,
+ type: 'profile',
+ profileType: type, // admin or user
+ author: {
+ did: authorData.did,
+ handle: authorData.handle,
+ displayName: authorData.displayName || authorData.handle,
+ avatar: authorData.avatar || null
+ },
+ createdAt: timestamp,
+ post: {
+ url: window.location.origin,
+ date: timestamp,
+ slug: '',
+ tags: [],
+ title: 'Profile',
+ language: 'ja'
+ }
+ }
+ }
+
+ // Post the record using agent like CommentForm
+ await agent.api.com.atproto.repo.putRecord(record)
+
+ // Invalidate cache and refresh
+ collections.invalidateCache(env.collection)
+
+ // 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 (
+
+
プロフィール投稿
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+ )
+}
+
+export default ProfileForm
\ No newline at end of file
diff --git a/ai-conversation/src/components/ProfileRecordList.jsx b/ai-conversation/src/components/ProfileRecordList.jsx
new file mode 100644
index 0000000..ea9124f
--- /dev/null
+++ b/ai-conversation/src/components/ProfileRecordList.jsx
@@ -0,0 +1,136 @@
+import React, { useState } from 'react'
+
+// Helper function to get correct web URL based on avatar URL
+function getCorrectWebUrl(avatarUrl) {
+ if (!avatarUrl) return 'https://bsky.app'
+
+ // If avatar is from bsky.app (main Bluesky), use bsky.app
+ if (avatarUrl.includes('cdn.bsky.app') || avatarUrl.includes('bsky.app')) {
+ return 'https://bsky.app'
+ }
+
+ // If avatar is from syu.is, use web.syu.is
+ if (avatarUrl.includes('bsky.syu.is') || avatarUrl.includes('syu.is')) {
+ return 'https://syu.is'
+ }
+
+ // Default to bsky.app
+ return 'https://bsky.app'
+}
+
+export default function ProfileRecordList({ profileRecords, apiConfig, user = null, agent = null, onRecordDeleted = null }) {
+ const [expandedRecords, setExpandedRecords] = useState(new Set())
+
+ const toggleJsonView = (uri) => {
+ const newExpanded = new Set(expandedRecords)
+ if (newExpanded.has(uri)) {
+ newExpanded.delete(uri)
+ } else {
+ newExpanded.add(uri)
+ }
+ setExpandedRecords(newExpanded)
+ }
+ if (!profileRecords || profileRecords.length === 0) {
+ return (
+
+ )
+ }
+
+ const handleDelete = async (profile) => {
+ if (!user || !agent || !profile.uri) return
+
+ const confirmed = window.confirm('このプロフィールを削除しますか?')
+ if (!confirmed) return
+
+ try {
+ const uriParts = profile.uri.split('/')
+ await agent.api.com.atproto.repo.deleteRecord({
+ repo: uriParts[2],
+ collection: uriParts[3],
+ rkey: uriParts[4]
+ })
+
+ if (onRecordDeleted) {
+ onRecordDeleted()
+ }
+ } catch (error) {
+ alert(`削除に失敗しました: ${error.message}`)
+ }
+ }
+
+ const canDelete = (profile) => {
+ if (!user || !agent || !profile.uri) return false
+
+ // Check if the record is in the current user's repository
+ const recordRepoDid = profile.uri.split('/')[2]
+ return recordRepoDid === user.did
+ }
+
+ return (
+
+ {profileRecords.map((profile) => (
+
+
+ {profile.value.author?.avatar ? (
+

+ ) : (
+
+ {(profile.value.author?.displayName || profile.value.author?.handle || '?').charAt(0).toUpperCase()}
+
+ )}
+
+
+ {profile.value.author?.displayName || profile.value.author?.handle}
+ {profile.value.profileType === 'admin' && (
+ Admin
+ )}
+
+
+
+
+
+ {canDelete(profile) && (
+
+ )}
+
+
+ {expandedRecords.has(profile.uri) && (
+
+
+ {JSON.stringify(profile, null, 2)}
+
+
+ )}
+
{profile.value.text}
+
+ ))}
+
+ )
+}
\ No newline at end of file
diff --git a/ai-conversation/src/components/RecordList.jsx b/ai-conversation/src/components/RecordList.jsx
new file mode 100644
index 0000000..452fdb6
--- /dev/null
+++ b/ai-conversation/src/components/RecordList.jsx
@@ -0,0 +1,154 @@
+import React, { useState } from 'react'
+import AvatarImage from './AvatarImage.jsx'
+import Avatar from './Avatar.jsx'
+
+// Helper function to get correct web URL based on avatar URL
+function getCorrectWebUrl(avatarUrl) {
+ if (!avatarUrl) return 'https://bsky.app'
+
+ // If avatar is from bsky.app (main Bluesky), use bsky.app
+ if (avatarUrl.includes('cdn.bsky.app') || avatarUrl.includes('bsky.app')) {
+ return 'https://bsky.app'
+ }
+
+ // If avatar is from syu.is, use web.syu.is
+ if (avatarUrl.includes('bsky.syu.is') || avatarUrl.includes('syu.is')) {
+ return 'https://syu.is'
+ }
+
+ // Default to bsky.app
+ return 'https://bsky.app'
+}
+
+export default function RecordList({ title, records, apiConfig, showTitle = true, user = null, agent = null, onRecordDeleted = null }) {
+ const [expandedRecords, setExpandedRecords] = useState(new Set())
+ const [deletingRecords, setDeletingRecords] = useState(new Set())
+
+ const toggleJsonView = (index) => {
+ const newExpanded = new Set(expandedRecords)
+ if (newExpanded.has(index)) {
+ newExpanded.delete(index)
+ } else {
+ newExpanded.add(index)
+ }
+ setExpandedRecords(newExpanded)
+ }
+
+ const handleDelete = async (record, index) => {
+ if (!user || !agent || !record.uri) return
+
+ const confirmed = window.confirm('このレコードを削除しますか?')
+ if (!confirmed) return
+
+ setDeletingRecords(prev => new Set([...prev, index]))
+
+ try {
+ // Extract repo, collection, rkey from URI
+ const uriParts = record.uri.split('/')
+ const repo = uriParts[2]
+ const collection = uriParts[3]
+ const rkey = uriParts[4]
+
+ await agent.com.atproto.repo.deleteRecord({
+ repo: repo,
+ collection: collection,
+ rkey: rkey
+ })
+
+ if (onRecordDeleted) {
+ onRecordDeleted()
+ }
+ } catch (error) {
+ alert(`削除に失敗しました: ${error.message}`)
+ } finally {
+ setDeletingRecords(prev => {
+ const newSet = new Set(prev)
+ newSet.delete(index)
+ return newSet
+ })
+ }
+ }
+
+ const canDelete = (record) => {
+ return user && agent && record.uri && record.value.author?.did === user.did
+ }
+ if (!records || records.length === 0) {
+ return (
+
+ {showTitle && {title} (0)
}
+ レコードがありません
+
+ )
+ }
+
+ return (
+
+ {showTitle && {title} ({records.length})
}
+ {records.map((record, i) => (
+
+
+
+
+
{record.value.author?.displayName || record.value.author?.handle}
+
+
{new Date(record.value.createdAt).toLocaleString()}
+
+
+
+
+
+ {canDelete(record) && (
+
+ )}
+
+
+
+
+
+ {expandedRecords.has(i) && (
+
+
+ {JSON.stringify(record, null, 2)}
+
+
+ )}
+
+
{record.value.text || record.value.content}
+
+ ))}
+
+ )
+}
diff --git a/ai-conversation/src/components/RecordTabs.jsx b/ai-conversation/src/components/RecordTabs.jsx
new file mode 100644
index 0000000..8a6de4d
--- /dev/null
+++ b/ai-conversation/src/components/RecordTabs.jsx
@@ -0,0 +1,214 @@
+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'
+import { logger } from '../utils/logger.js'
+
+export default function RecordTabs({ langRecords, commentRecords, userComments, chatRecords, userChatRecords, userChatLoading, baseRecords, apiConfig, pageContext, user = null, agent = null, onRecordDeleted = null }) {
+ const [activeTab, setActiveTab] = useState('profiles')
+
+ logger.log('RecordTabs: activeTab is', activeTab)
+
+ // Filter records based on page context
+ const filterRecords = (records, isProfile = false) => {
+ if (pageContext.isTopPage) {
+ // Top page: show latest 3 records
+ return records.slice(0, 3)
+ } else {
+ // Individual page: show records matching the URL
+ return records.filter(record => {
+ // Profile records should always be shown
+ if (isProfile || record.value?.type === 'profile') {
+ return true
+ }
+
+ const recordUrl = record.value?.post?.url
+ if (!recordUrl) return false
+
+ try {
+ const recordRkey = new URL(recordUrl).pathname.split('/').pop()?.replace(/\.html$/, '')
+ return recordRkey === pageContext.rkey
+ } catch {
+ return false
+ }
+ })
+ }
+ }
+
+ // Special filter for chat records (which are already processed into pairs)
+ const filterChatRecords = (chatPairs) => {
+ console.log('filterChatRecords called:', {
+ isTopPage: pageContext.isTopPage,
+ rkey: pageContext.rkey,
+ chatPairsLength: chatPairs.length
+ })
+
+ if (pageContext.isTopPage) {
+ // Top page: show latest 3 pairs
+ const result = chatPairs.slice(0, 3)
+ console.log('Top page: returning', result.length, 'pairs')
+ return result
+ } else {
+ // Individual page: show pairs matching the URL (compare path only, ignore domain)
+ const filtered = chatPairs.filter(chatPair => {
+ const recordUrl = chatPair.question?.value?.post?.url
+ if (!recordUrl) {
+ console.log('No recordUrl for chatPair:', chatPair)
+ return false
+ }
+
+ try {
+ // Extract path from URL and get the filename part
+ const recordPath = new URL(recordUrl).pathname
+ const recordRkey = recordPath.split('/').pop()?.replace(/\.html$/, '')
+
+ console.log('Comparing:', { recordRkey, pageRkey: pageContext.rkey, recordUrl })
+
+ // Compare with current page rkey
+ const matches = recordRkey === pageContext.rkey
+ if (matches) {
+ console.log('Found matching chat pair!')
+ }
+ return matches
+ } catch (error) {
+ console.log('Error processing recordUrl:', recordUrl, error)
+ return false
+ }
+ })
+
+ console.log('Individual page: returning', filtered.length, 'filtered pairs')
+ return filtered
+ }
+ }
+
+ const filteredLangRecords = filterRecords(langRecords)
+ const filteredCommentRecords = filterRecords(commentRecords)
+ const filteredUserComments = filterRecords(userComments || [])
+ const filteredChatRecords = filterChatRecords(chatRecords || [])
+ const filteredBaseRecords = filterRecords(baseRecords || [])
+
+ // Filter profile records from baseRecords
+ const profileRecords = (baseRecords || []).filter(record => record.value?.type === 'profile')
+ const sortedProfileRecords = profileRecords.sort((a, b) => {
+ if (a.value.profileType === 'admin' && b.value.profileType !== 'admin') return -1
+ if (a.value.profileType !== 'admin' && b.value.profileType === 'admin') return 1
+ return 0
+ })
+ const filteredProfileRecords = filterRecords(sortedProfileRecords, true)
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {activeTab === 'lang' && (
+ !langRecords ? (
+
+ ) : (
+
+ )
+ )}
+ {activeTab === 'comment' && (
+ !commentRecords ? (
+
+ ) : (
+
+ )
+ )}
+ {activeTab === 'collection' && (
+ userChatLoading ? (
+
+ ) : (
+
0 ? filteredChatRecords : userChatRecords}
+ apiConfig={apiConfig}
+ user={user}
+ agent={agent}
+ onRecordDeleted={onRecordDeleted}
+ />
+ )
+ )}
+ {activeTab === 'users' && (
+ !userComments ? (
+
+ ) : (
+
+ )
+ )}
+ {activeTab === 'profiles' && (
+ !baseRecords ? (
+
+ ) : (
+
+ )
+ )}
+
+
+
+ )
+}
diff --git a/ai-conversation/src/components/TestUI.jsx b/ai-conversation/src/components/TestUI.jsx
new file mode 100644
index 0000000..fe1d1c0
--- /dev/null
+++ b/ai-conversation/src/components/TestUI.jsx
@@ -0,0 +1,531 @@
+import React, { useState } from 'react'
+import { env } from '../config/env.js'
+import AvatarTestPanel from './AvatarTestPanel.jsx'
+import AvatarTest from './AvatarTest.jsx'
+
+export default function TestUI() {
+ const [activeTab, setActiveTab] = useState('putRecord')
+ const [accessJwt, setAccessJwt] = useState('')
+ const [handle, setHandle] = useState('')
+ const [sessionDid, setSessionDid] = useState('')
+ const [collection, setCollection] = useState('ai.syui.log')
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+ const [success, setSuccess] = useState(null)
+ const [showJson, setShowJson] = useState(false)
+ const [lastRecord, setLastRecord] = useState(null)
+
+ const collections = [
+ 'ai.syui.log',
+ 'ai.syui.log.chat',
+ 'ai.syui.log.chat.lang',
+ 'ai.syui.log.chat.comment'
+ ]
+
+ const generateDummyData = (collectionType) => {
+ const timestamp = new Date().toISOString()
+ const url = 'https://syui.ai/test/dummy'
+
+ const basePost = {
+ url: url,
+ date: timestamp,
+ slug: 'dummy-test',
+ tags: ['test', 'dummy'],
+ title: 'Test Post',
+ language: 'ja'
+ }
+
+ const baseAuthor = {
+ did: sessionDid || null, // Use real session DID if available, otherwise null
+ handle: handle || 'test.user',
+ displayName: 'Test User',
+ avatar: null
+ }
+
+ switch (collectionType) {
+ case 'ai.syui.log':
+ return {
+ $type: collectionType,
+ url: url,
+ post: basePost,
+ text: 'テストコメントです。これはダミーデータです。',
+ type: 'comment',
+ author: baseAuthor,
+ createdAt: timestamp
+ }
+
+ case 'ai.syui.log.chat':
+ const isQuestion = Math.random() > 0.5
+ return {
+ $type: collectionType,
+ post: basePost,
+ text: isQuestion ? 'これはテスト用の質問です。' : 'これはテスト用のAI回答です。詳しく説明します。',
+ type: isQuestion ? 'question' : 'answer',
+ author: isQuestion ? baseAuthor : {
+ did: 'did:plc:ai-test',
+ handle: 'ai.syui.ai',
+ displayName: 'ai',
+ avatar: null
+ },
+ createdAt: timestamp
+ }
+
+ case 'ai.syui.log.chat.lang':
+ return {
+ $type: collectionType,
+ post: basePost,
+ text: 'This is a test translation. Hello, this is a dummy English translation of the Japanese post.',
+ type: 'en',
+ author: {
+ did: 'did:plc:ai-test',
+ handle: 'ai.syui.ai',
+ displayName: 'ai',
+ avatar: null
+ },
+ createdAt: timestamp
+ }
+
+ case 'ai.syui.log.chat.comment':
+ return {
+ $type: collectionType,
+ post: basePost,
+ text: 'これはAIによるテストコメントです。記事についての感想や補足情報を提供します。',
+ author: {
+ did: 'did:plc:ai-test',
+ handle: 'ai.syui.ai',
+ displayName: 'ai',
+ avatar: null
+ },
+ createdAt: timestamp
+ }
+
+ default:
+ return {}
+ }
+ }
+
+ const handleSubmit = async (e) => {
+ e.preventDefault()
+ if (!accessJwt.trim() || !handle.trim()) {
+ setError('Access JWT and Handle are required')
+ return
+ }
+
+ setLoading(true)
+ setError(null)
+ setSuccess(null)
+
+ try {
+ const recordData = generateDummyData(collection)
+ const rkey = `test-${Date.now()}`
+
+ const record = {
+ repo: handle, // Use handle as is, without adding .bsky.social
+ collection: collection,
+ rkey: rkey,
+ record: recordData
+ }
+
+ setLastRecord(record)
+
+ // Direct API call with accessJwt
+ const response = await fetch(`https://${env.pds}/xrpc/com.atproto.repo.putRecord`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${accessJwt}`
+ },
+ body: JSON.stringify(record)
+ })
+
+ if (!response.ok) {
+ const errorData = await response.json()
+ throw new Error(`API Error: ${response.status} - ${errorData.message || response.statusText}`)
+ }
+
+ const result = await response.json()
+ setSuccess(`Record created successfully! URI: ${result.uri}`)
+
+ } catch (err) {
+ setError(err.message)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleDelete = async () => {
+ if (!lastRecord || !accessJwt.trim()) {
+ setError('No record to delete or missing access JWT')
+ return
+ }
+
+ setLoading(true)
+ setError(null)
+
+ try {
+ const deleteData = {
+ repo: lastRecord.repo,
+ collection: lastRecord.collection,
+ rkey: lastRecord.rkey
+ }
+
+ const response = await fetch(`https://${env.pds}/xrpc/com.atproto.repo.deleteRecord`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${accessJwt}`
+ },
+ body: JSON.stringify(deleteData)
+ })
+
+ if (!response.ok) {
+ const errorData = await response.json()
+ throw new Error(`Delete Error: ${response.status} - ${errorData.message || response.statusText}`)
+ }
+
+ setSuccess('Record deleted successfully!')
+ setLastRecord(null)
+
+ } catch (err) {
+ setError(err.message)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+
+
🧪 Test UI
+
+ {/* Tab Navigation */}
+
+
+
+
+
+ {activeTab === 'putRecord' && (
+
+
+ OAuth不要のテスト用UI。accessJwtとhandleを直接入力して各collectionにダミーデータを投稿できます。
+
+
+
+
+ {showJson && (
+
+
Generated JSON:
+
{JSON.stringify(generateDummyData(collection), null, 2)}
+
+ )}
+
+ {lastRecord && (
+
+
Last Created Record:
+
+
Collection: {lastRecord.collection}
+
RKey: {lastRecord.rkey}
+
Repo: {lastRecord.repo}
+
+
+ )}
+
+ )}
+
+ {activeTab === 'avatar' && (
+
+ )}
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/ai-conversation/src/components/UserLookup.jsx b/ai-conversation/src/components/UserLookup.jsx
new file mode 100644
index 0000000..c9d4354
--- /dev/null
+++ b/ai-conversation/src/components/UserLookup.jsx
@@ -0,0 +1,115 @@
+import React, { useState } from 'react'
+import { atproto } from '../api/atproto.js'
+import { getPdsFromHandle, getApiConfig } from '../utils/pds.js'
+
+export default function UserLookup() {
+ const [handleInput, setHandleInput] = useState('')
+ const [userInfo, setUserInfo] = useState(null)
+ const [loading, setLoading] = useState(false)
+
+ const handleSubmit = async (e) => {
+ e.preventDefault()
+ if (!handleInput.trim() || loading) return
+
+ setLoading(true)
+ try {
+ const userPds = await getPdsFromHandle(handleInput)
+ const apiConfig = getApiConfig(userPds)
+ const did = await atproto.getDid(userPds.replace('https://', ''), handleInput)
+ const profile = await atproto.getProfile(apiConfig.bsky, did)
+
+ setUserInfo({
+ handle: handleInput,
+ pds: userPds,
+ did,
+ profile,
+ config: apiConfig
+ })
+ } catch (error) {
+ console.error('User lookup failed:', error)
+ setUserInfo({ error: error.message })
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+
+ ユーザー検索
+
+
+ {userInfo && (
+
+
ユーザー情報:
+ {userInfo.error ? (
+
エラー: {userInfo.error}
+ ) : (
+
+
Handle: {userInfo.handle}
+
PDS: {userInfo.pds}
+
DID: {userInfo.did}
+
Display Name: {userInfo.profile?.displayName}
+
PDS API: {userInfo.config?.pds}
+
Bsky API: {userInfo.config?.bsky}
+
Web: {userInfo.config?.web}
+
+ )}
+
+ )}
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/ai-conversation/src/config/env.js b/ai-conversation/src/config/env.js
new file mode 100644
index 0000000..eea1efa
--- /dev/null
+++ b/ai-conversation/src/config/env.js
@@ -0,0 +1,17 @@
+// Environment configuration
+export const env = {
+ admin: import.meta.env.VITE_ADMIN,
+ pds: import.meta.env.VITE_PDS,
+ collection: import.meta.env.VITE_COLLECTION,
+ handleList: (() => {
+ try {
+ return JSON.parse(import.meta.env.VITE_HANDLE_LIST || '[]')
+ } catch {
+ return []
+ }
+ })(),
+ oauth: {
+ clientId: import.meta.env.VITE_OAUTH_CLIENT_ID,
+ redirectUri: import.meta.env.VITE_OAUTH_REDIRECT_URI
+ }
+}
\ No newline at end of file
diff --git a/ai-conversation/src/hooks/useAdminData.js b/ai-conversation/src/hooks/useAdminData.js
new file mode 100644
index 0000000..ccc9767
--- /dev/null
+++ b/ai-conversation/src/hooks/useAdminData.js
@@ -0,0 +1,135 @@
+import { useState, useEffect } from 'react'
+import { atproto, collections } from '../api/atproto.js'
+import { getApiConfig } from '../utils/pds.js'
+import { env } from '../config/env.js'
+import { getErrorMessage } from '../utils/errorHandler.js'
+
+export function useAdminData() {
+ const [adminData, setAdminData] = useState({
+ did: '',
+ profile: null,
+ records: [],
+ apiConfig: null
+ })
+ const [langRecords, setLangRecords] = useState([])
+ const [commentRecords, setCommentRecords] = useState([])
+ const [chatRecords, setChatRecords] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+
+ useEffect(() => {
+ loadAdminData()
+ }, [])
+
+ const loadAdminData = async () => {
+ try {
+ setLoading(true)
+ setError(null)
+
+ const apiConfig = getApiConfig(`https://${env.pds}`)
+ const did = await atproto.getDid(env.pds, env.admin)
+ const profile = await atproto.getProfile(apiConfig.bsky, did)
+
+ // Load all data in parallel
+ const [records, lang, comment, chat] = await Promise.all([
+ collections.getBase(apiConfig.pds, did, env.collection),
+ collections.getLang(apiConfig.pds, did, env.collection),
+ collections.getComment(apiConfig.pds, did, env.collection),
+ collections.getChat(apiConfig.pds, did, env.collection)
+ ])
+
+ // Profile records are in the main collection, not separate profile collection
+ const profileRecords = records.filter(record => record.value.type === 'profile')
+
+ // Create profile map for enhanced author information
+ const profileMap = new Map()
+ profileRecords.forEach(record => {
+ if (record.value.type === 'profile') {
+ profileMap.set(record.value.author?.did, record.value.author)
+ }
+ })
+
+ // Get user profile from profile collection (syui.syui.ai)
+ const userProfile = profileRecords.find(record =>
+ record.value.type === 'profile' && record.value.author?.handle === 'syui.syui.ai'
+ )?.value.author
+
+ // Get AI profile from admin account (ai.syui.ai)
+ const aiProfile = {
+ did: did,
+ handle: env.admin,
+ displayName: profile.displayName || "ai",
+ avatar: profile.avatar || `https://bsky.syu.is/img/avatar/plain/${did}/default@jpeg`
+ }
+
+ // Process chat records into question-answer pairs
+ const chatPairs = []
+ const recordMap = new Map()
+
+ // First pass: organize records by base rkey
+ chat.forEach(record => {
+ const rkey = record.uri.split('/').pop()
+ const baseRkey = rkey.replace('-answer', '')
+
+ if (!recordMap.has(baseRkey)) {
+ recordMap.set(baseRkey, { question: null, answer: null })
+ }
+
+ // Override author information based on record type
+ if (record.value.type === 'question' && userProfile) {
+ // Questions should show user profile (syui.syui.ai)
+ record.value.author = userProfile
+ } else if (record.value.type === 'answer') {
+ // Answers should show AI profile (ai.syui.ai)
+ record.value.author = aiProfile
+ }
+
+ if (record.value.type === 'question') {
+ recordMap.get(baseRkey).question = record
+ } else if (record.value.type === 'answer') {
+ recordMap.get(baseRkey).answer = record
+ }
+ })
+
+ // Second pass: create chat pairs
+ recordMap.forEach((pair, rkey) => {
+ if (pair.question) {
+ chatPairs.push({
+ rkey,
+ question: pair.question,
+ answer: pair.answer,
+ createdAt: pair.question.value.createdAt
+ })
+ }
+ })
+
+ // Sort by creation time (oldest first for conversation flow)
+ chatPairs.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))
+
+ console.log('useAdminData: raw chat records:', chat.length)
+ console.log('useAdminData: processed chat pairs:', chatPairs.length, chatPairs)
+ console.log('useAdminData: profile map:', profileMap)
+ console.log('useAdminData: profile records:', profileRecords)
+
+ setAdminData({ did, profile, records, apiConfig })
+ setLangRecords(lang)
+ setCommentRecords(comment)
+ setChatRecords(chatPairs)
+ } catch (err) {
+ // Silently fail - no error logging or retry attempts
+ setError('silent_failure')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return {
+ adminData,
+ langRecords,
+ commentRecords,
+ chatRecords,
+ loading,
+ error,
+ refresh: loadAdminData
+ }
+}
\ No newline at end of file
diff --git a/ai-conversation/src/hooks/useAskAI.js b/ai-conversation/src/hooks/useAskAI.js
new file mode 100644
index 0000000..90a4974
--- /dev/null
+++ b/ai-conversation/src/hooks/useAskAI.js
@@ -0,0 +1,230 @@
+import { useState } from 'react'
+import { atproto, collections } from '../api/atproto.js'
+import { env } from '../config/env.js'
+import { logger } from '../utils/logger.js'
+import { getErrorMessage, logError } from '../utils/errorHandler.js'
+import { AIProviderFactory } from '../services/aiProvider.js'
+
+export function useAskAI(adminData, userProfile, agent) {
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+ const [chatHistory, setChatHistory] = useState([])
+
+ // AIプロバイダーを環境変数から作成
+ const aiProvider = AIProviderFactory.createFromEnv()
+
+ const askQuestion = async (question) => {
+ if (!question.trim()) return
+
+ setLoading(true)
+ setError(null)
+
+ try {
+ logger.log('Sending question to AI provider:', question)
+
+ // AIプロバイダーに質問を送信
+ const aiResponse = await aiProvider.ask(question, {
+ userProfile: userProfile
+ })
+
+ logger.log('Received AI response:', aiResponse)
+
+ // AI回答をチャット履歴に追加
+ const chatEntry = {
+ id: `chat-${Date.now()}`,
+ question: question.trim(),
+ answer: aiResponse.answer || 'エラーが発生しました',
+ timestamp: new Date().toISOString(),
+ user: userProfile ? {
+ did: userProfile.did,
+ handle: userProfile.handle,
+ displayName: userProfile.displayName,
+ avatar: userProfile.avatar
+ } : null
+ }
+
+ setChatHistory(prev => [...prev, chatEntry])
+
+ // atprotoにレコードを保存
+ await saveChatRecord(chatEntry, aiResponse)
+
+ // Dispatch event for blog communication
+ window.dispatchEvent(new CustomEvent('aiResponseReceived', {
+ detail: {
+ question: chatEntry.question,
+ answer: chatEntry.answer,
+ timestamp: chatEntry.timestamp,
+ aiProfile: adminData?.profile ? {
+ did: adminData.did,
+ handle: adminData.profile.handle,
+ displayName: adminData.profile.displayName,
+ avatar: adminData.profile.avatar
+ } : null
+ }
+ }))
+
+ return aiResponse
+
+ } catch (err) {
+ logError(err, 'useAskAI.askQuestion')
+
+ let errorMessage = 'AI応答の生成に失敗しました'
+ if (err.message.includes('Request timeout')) {
+ errorMessage = 'AI応答がタイムアウトしました'
+ } else if (err.message.includes('API error')) {
+ errorMessage = `API エラー: ${err.message}`
+ } else if (err.message.includes('Failed to fetch')) {
+ errorMessage = 'AI サーバーに接続できませんでした'
+ }
+
+ setError(errorMessage)
+ throw err
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const saveChatRecord = async (chatEntry, aiResponse) => {
+ if (!agent || !adminData?.did) {
+ logger.warn('Cannot save chat record: missing agent or admin data')
+ return
+ }
+
+ try {
+ const currentUrl = window.location.href
+ const timestamp = chatEntry.timestamp
+ const baseRkey = `${new Date(timestamp).toISOString().replace(/[:.]/g, '-').slice(0, -5)}Z`
+
+ // Post metadata (共通)
+ const postMetadata = {
+ url: currentUrl,
+ date: timestamp,
+ slug: new URL(currentUrl).pathname.split('/').pop()?.replace(/\.html$/, '') || '',
+ tags: [],
+ title: document.title || 'AI Chat',
+ language: 'ja'
+ }
+
+ // Question record (ユーザーの質問)
+ const questionRecord = {
+ repo: adminData.did,
+ collection: `${env.collection}.chat`,
+ rkey: baseRkey,
+ record: {
+ $type: `${env.collection}.chat`,
+ post: postMetadata,
+ text: chatEntry.question,
+ type: 'question',
+ author: chatEntry.user ? {
+ did: chatEntry.user.did,
+ handle: chatEntry.user.handle,
+ displayName: chatEntry.user.displayName,
+ avatar: chatEntry.user.avatar
+ } : {
+ did: 'unknown',
+ handle: 'user',
+ displayName: 'User',
+ avatar: null
+ },
+ createdAt: timestamp
+ }
+ }
+
+ // Answer record (AIの回答)
+ const answerRecord = {
+ repo: adminData.did,
+ collection: `${env.collection}.chat`,
+ rkey: `${baseRkey}-answer`,
+ record: {
+ $type: `${env.collection}.chat`,
+ post: postMetadata,
+ text: chatEntry.answer,
+ type: 'answer',
+ author: {
+ did: adminData.did,
+ handle: adminData.profile?.handle || 'ai',
+ displayName: adminData.profile?.displayName || 'ai',
+ avatar: adminData.profile?.avatar || null
+ },
+ createdAt: timestamp
+ }
+ }
+
+ logger.log('Saving question record to atproto:', questionRecord)
+ await atproto.putRecord(null, questionRecord, agent)
+
+ logger.log('Saving answer record to atproto:', answerRecord)
+ await atproto.putRecord(null, answerRecord, agent)
+
+ // キャッシュを無効化
+ collections.invalidateCache(env.collection)
+
+ logger.log('Chat records saved successfully')
+
+ } catch (err) {
+ logError(err, 'useAskAI.saveChatRecord')
+ // 保存エラーは致命的ではないので、UIエラーにはしない
+ }
+ }
+
+ const clearChatHistory = () => {
+ setChatHistory([])
+ setError(null)
+ }
+
+ const loadChatHistory = async () => {
+ if (!adminData?.did) return
+
+ try {
+ const records = await collections.getChat(
+ adminData.apiConfig.pds,
+ adminData.did,
+ env.collection
+ )
+
+ // Group records by timestamp and create Q&A pairs
+ const recordGroups = {}
+
+ records.forEach(record => {
+ const timestamp = record.value.createdAt
+ const baseKey = timestamp.replace('-answer', '')
+
+ if (!recordGroups[baseKey]) {
+ recordGroups[baseKey] = {}
+ }
+
+ if (record.value.type === 'question') {
+ recordGroups[baseKey].question = record.value.text
+ recordGroups[baseKey].user = record.value.author
+ recordGroups[baseKey].timestamp = timestamp
+ recordGroups[baseKey].id = record.uri
+ } else if (record.value.type === 'answer') {
+ recordGroups[baseKey].answer = record.value.text
+ recordGroups[baseKey].timestamp = timestamp
+ }
+ })
+
+ // Convert to history format, only include complete Q&A pairs
+ const history = Object.values(recordGroups)
+ .filter(group => group.question && group.answer)
+ .sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp))
+ .slice(-10) // 最新10件のみ
+
+ setChatHistory(history)
+ logger.log('Chat history loaded:', history.length, 'entries')
+
+ } catch (err) {
+ logError(err, 'useAskAI.loadChatHistory')
+ // 履歴読み込みエラーは致命的ではない
+ }
+ }
+
+ return {
+ askQuestion,
+ loading,
+ error,
+ chatHistory,
+ clearChatHistory,
+ loadChatHistory
+ }
+}
\ No newline at end of file
diff --git a/ai-conversation/src/hooks/useAuth.js b/ai-conversation/src/hooks/useAuth.js
new file mode 100644
index 0000000..df51dc1
--- /dev/null
+++ b/ai-conversation/src/hooks/useAuth.js
@@ -0,0 +1,80 @@
+import { useState, useEffect } from 'react'
+import { OAuthService } from '../services/oauth.js'
+
+const oauthService = new OAuthService()
+
+export function useAuth() {
+ const [user, setUser] = useState(null)
+ const [agent, setAgent] = useState(null)
+ const [loading, setLoading] = useState(true)
+
+ useEffect(() => {
+ initAuth()
+ }, [])
+
+ const initAuth = async () => {
+ try {
+ const authResult = await oauthService.checkAuth()
+ if (authResult) {
+ setUser(authResult.user)
+ setAgent(authResult.agent)
+
+ // If we're on callback page and authentication succeeded, notify parent
+ if (window.location.pathname === '/oauth/callback') {
+ console.log('OAuth callback completed, notifying parent window')
+
+ // Get referrer or use stored return URL
+ const returnUrl = sessionStorage.getItem('oauth_return_url') ||
+ document.referrer ||
+ window.location.origin
+
+ sessionStorage.removeItem('oauth_return_url')
+
+ // Notify parent window if in iframe, otherwise redirect directly
+ if (window.parent !== window) {
+ window.parent.postMessage({
+ type: 'oauth_success',
+ returnUrl: returnUrl,
+ user: authResult.user
+ }, '*')
+ } else {
+ // Set flag to skip loading screen after redirect
+ sessionStorage.setItem('oauth_just_completed', 'true')
+ // Direct redirect
+ setTimeout(() => {
+ window.location.href = returnUrl
+ }, 1000)
+ }
+ }
+ }
+ } catch (error) {
+ console.error('Auth initialization failed:', error)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const login = async (handle) => {
+ // Store current page URL for post-auth redirect
+ if (window.location.pathname !== '/oauth/callback') {
+ sessionStorage.setItem('oauth_return_url', window.location.href)
+ }
+
+ await oauthService.login(handle)
+ }
+
+ const logout = async () => {
+ await oauthService.logout()
+ setUser(null)
+ setAgent(null)
+ }
+
+ return {
+ user,
+ agent,
+ loading,
+ login,
+ logout,
+ isAuthenticated: !!user
+ }
+}
\ No newline at end of file
diff --git a/ai-conversation/src/hooks/usePageContext.js b/ai-conversation/src/hooks/usePageContext.js
new file mode 100644
index 0000000..4ca82de
--- /dev/null
+++ b/ai-conversation/src/hooks/usePageContext.js
@@ -0,0 +1,33 @@
+import { useState, useEffect } from 'react'
+
+export function usePageContext() {
+ const [pageContext, setPageContext] = useState({
+ isTopPage: true,
+ rkey: null,
+ url: null
+ })
+
+ useEffect(() => {
+ const pathname = window.location.pathname
+ const url = window.location.href
+
+ // Extract rkey from URL pattern: /posts/xxx or /posts/xxx.html
+ const match = pathname.match(/\/posts\/([^/]+)\/?$/)
+ if (match) {
+ const rkey = match[1].replace(/\.html$/, '')
+ setPageContext({
+ isTopPage: false,
+ rkey,
+ url
+ })
+ } else {
+ setPageContext({
+ isTopPage: true,
+ rkey: null,
+ url
+ })
+ }
+ }, [])
+
+ return pageContext
+}
\ No newline at end of file
diff --git a/ai-conversation/src/hooks/useUserData.js b/ai-conversation/src/hooks/useUserData.js
new file mode 100644
index 0000000..26ac3ae
--- /dev/null
+++ b/ai-conversation/src/hooks/useUserData.js
@@ -0,0 +1,215 @@
+import { useState, useEffect } from 'react'
+import { atproto, collections } from '../api/atproto.js'
+import { getApiConfig, isSyuIsHandle, getPdsFromHandle } from '../utils/pds.js'
+import { env } from '../config/env.js'
+
+export function useUserData(adminData) {
+ const [userComments, setUserComments] = useState([])
+ const [chatRecords, setChatRecords] = useState([])
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+
+ useEffect(() => {
+ if (!adminData?.did || !adminData?.apiConfig) return
+
+ const fetchUserData = async () => {
+ setLoading(true)
+ setError(null)
+
+ try {
+ // 1. Get user list from admin account
+ const userListRecords = await collections.getUserList(
+ adminData.apiConfig.pds,
+ adminData.did,
+ env.collection
+ )
+
+ // 2. Get chat records from ai.syui.log.chat and process into pairs
+ const chatRecords = await collections.getChat(
+ adminData.apiConfig.pds,
+ adminData.did,
+ env.collection
+ )
+
+ console.log('useUserData: raw chatRecords:', chatRecords.length, chatRecords)
+
+ // Process chat records into question-answer pairs
+ const chatPairs = []
+ const recordMap = new Map()
+
+ // First pass: organize records by base rkey
+ chatRecords.forEach(record => {
+ const rkey = record.uri.split('/').pop()
+ const baseRkey = rkey.replace('-answer', '')
+
+ if (!recordMap.has(baseRkey)) {
+ recordMap.set(baseRkey, { question: null, answer: null })
+ }
+
+ if (record.value.type === 'question') {
+ recordMap.get(baseRkey).question = record
+ } else if (record.value.type === 'answer') {
+ recordMap.get(baseRkey).answer = record
+ }
+ })
+
+ // Second pass: create chat pairs
+ recordMap.forEach((pair, rkey) => {
+ if (pair.question) {
+ chatPairs.push({
+ rkey,
+ question: pair.question,
+ answer: pair.answer,
+ createdAt: pair.question.value.createdAt
+ })
+ }
+ })
+
+ // Sort by creation time (newest first)
+ chatPairs.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
+
+ console.log('useUserData: processed chatPairs:', chatPairs.length, chatPairs)
+ setChatRecords(chatPairs)
+
+ // 3. Get base collection records which contain user comments
+ const baseRecords = await collections.getBase(
+ adminData.apiConfig.pds,
+ adminData.did,
+ env.collection
+ )
+
+ // Extract comments from base records
+ const allUserComments = []
+
+ for (const record of baseRecords) {
+ if (record.value?.comments && Array.isArray(record.value.comments)) {
+ // Each comment already has author info, so we can use it directly
+ const commentsWithMeta = record.value.comments.map(comment => ({
+ uri: record.uri,
+ cid: record.cid,
+ value: {
+ ...comment,
+ post: {
+ url: record.value.url
+ }
+ }
+ }))
+ allUserComments.push(...commentsWithMeta)
+ }
+ }
+
+ // Also try to get individual user records from the user list
+ // Currently skipping user list processing since users contain placeholder DIDs
+ if (userListRecords.length > 0 && userListRecords[0].value?.users) {
+ console.log('User list found, but skipping placeholder users for now')
+
+ // Filter out placeholder users
+ const realUsers = userListRecords[0].value.users.filter(user =>
+ user.handle &&
+ user.did &&
+ !user.did.includes('placeholder') &&
+ !user.did.includes('example')
+ )
+
+ if (realUsers.length > 0) {
+ console.log(`Processing ${realUsers.length} real users`)
+
+ for (const user of realUsers) {
+ const userHandle = user.handle
+
+ try {
+ // Get user's DID and PDS using PDS detection logic
+ let userDid, userPds, userApiConfig
+
+ if (user.did && user.pds) {
+ // Use DID and PDS from user record
+ userDid = user.did
+ userPds = user.pds.replace('https://', '')
+ userApiConfig = getApiConfig(userPds)
+ } else {
+ // Always get actual PDS from describeRepo first
+ try {
+ // Try bsky.social first for most handles
+ const bskyPds = 'bsky.social'
+ userDid = await atproto.getDid(bskyPds, userHandle)
+
+ // Get the actual PDS endpoint from DID
+ const realPds = await getPdsFromHandle(userHandle)
+ userPds = realPds.replace('https://', '')
+ userApiConfig = getApiConfig(realPds)
+ } catch (error) {
+ // Fallback to syu.is if bsky.social fails
+ console.warn(`Failed to get PDS for ${userHandle} from bsky.social, trying syu.is:`, error)
+ userPds = env.pds
+ userApiConfig = getApiConfig(env.pds)
+ userDid = await atproto.getDid(userPds, userHandle)
+ }
+ }
+
+ // Get user's own ai.syui.log records
+ const userRecords = await collections.getUserComments(
+ userApiConfig.pds,
+ userDid,
+ env.collection
+ )
+
+ // Skip if no records found
+ if (!userRecords || userRecords.length === 0) {
+ continue
+ }
+
+ // Get user's profile for enrichment
+ let profile = null
+ try {
+ profile = await atproto.getProfile(userApiConfig.bsky, userDid)
+ } catch (profileError) {
+ console.warn(`Failed to get profile for ${userHandle}:`, profileError)
+ }
+
+ // Add profile info to each record
+ const enrichedRecords = userRecords.map(record => ({
+ ...record,
+ value: {
+ ...record.value,
+ author: {
+ did: userDid,
+ handle: profile?.data?.handle || userHandle,
+ displayName: profile?.data?.displayName || userHandle,
+ avatar: profile?.data?.avatar || null
+ }
+ }
+ }))
+
+ allUserComments.push(...enrichedRecords)
+ } catch (userError) {
+ console.warn(`Failed to fetch data for user ${userHandle}:`, userError)
+ }
+ }
+ } else {
+ console.log('No real users found in user list - all appear to be placeholders')
+ }
+ }
+
+ setUserComments(allUserComments)
+ } catch (err) {
+ setError(err.message)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ fetchUserData()
+ }, [adminData])
+
+ const refresh = () => {
+ if (adminData?.did && adminData?.apiConfig) {
+ // Re-trigger the effect by clearing and re-setting adminData
+ const currentAdminData = adminData
+ setUserComments([])
+ setChatRecords([])
+ // The useEffect will automatically run again
+ }
+ }
+
+ return { userComments, chatRecords, loading, error, refresh }
+}
\ No newline at end of file
diff --git a/ai-conversation/src/main.jsx b/ai-conversation/src/main.jsx
new file mode 100644
index 0000000..5bf4036
--- /dev/null
+++ b/ai-conversation/src/main.jsx
@@ -0,0 +1,10 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import App from './App'
+import './App.css'
+
+// Only mount the AI conversation app if the target element exists
+const targetElement = document.getElementById('ai-conversation-atproto')
+if (targetElement) {
+ ReactDOM.createRoot(targetElement).render()
+}
\ No newline at end of file
diff --git a/ai-conversation/src/services/aiProvider.js b/ai-conversation/src/services/aiProvider.js
new file mode 100644
index 0000000..654bc2c
--- /dev/null
+++ b/ai-conversation/src/services/aiProvider.js
@@ -0,0 +1,214 @@
+/**
+ * AI Provider Abstract Interface
+ * Supports multiple AI backends (Ollama, Claude, etc.)
+ */
+
+export class AIProvider {
+ constructor(config) {
+ this.config = config
+ }
+
+ /**
+ * Send a question to the AI and get a response
+ * @param {string} question - User's question
+ * @param {Object} context - Additional context (user info, etc.)
+ * @returns {Promise<{answer: string}>}
+ */
+ async ask(question, context = {}) {
+ throw new Error('ask() method must be implemented by subclass')
+ }
+
+ /**
+ * Check if the provider is available
+ * @returns {Promise}
+ */
+ async healthCheck() {
+ throw new Error('healthCheck() method must be implemented by subclass')
+ }
+}
+
+/**
+ * Ollama Provider Implementation
+ */
+export class OllamaProvider extends AIProvider {
+ constructor(config) {
+ super(config)
+ this.host = config.host || 'https://ollama.syui.ai'
+ this.model = config.model || 'gemma3:1b'
+ this.systemPrompt = config.systemPrompt || ''
+ }
+
+ async ask(question, context = {}) {
+ // Build enhanced prompt with user context
+ const userInfo = context.userProfile
+ ? `相手の名前は${context.userProfile.displayName || context.userProfile.handle}です。`
+ : ''
+ const enhancedSystemPrompt = `${this.systemPrompt} ${userInfo}`
+
+ const prompt = `${enhancedSystemPrompt}
+
+Question: ${question}
+
+Answer:`
+
+ const controller = new AbortController()
+ const timeoutId = setTimeout(() => controller.abort(), 30000)
+
+ try {
+ const response = await fetch(`${this.host}/api/generate`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Origin': 'https://syui.ai',
+ },
+ body: JSON.stringify({
+ model: this.model,
+ prompt: prompt,
+ stream: false,
+ options: {
+ temperature: 0.9,
+ top_p: 0.9,
+ num_predict: 200,
+ repeat_penalty: 1.1,
+ }
+ }),
+ signal: controller.signal
+ })
+
+ clearTimeout(timeoutId)
+
+ if (!response.ok) {
+ throw new Error(`Ollama API error: ${response.status}`)
+ }
+
+ const data = await response.json()
+ return { answer: data.response || 'エラーが発生しました' }
+
+ } catch (error) {
+ clearTimeout(timeoutId)
+ if (error.name === 'AbortError') {
+ throw new Error('Request timeout')
+ }
+ throw error
+ }
+ }
+
+ async healthCheck() {
+ try {
+ const response = await fetch(`${this.host}/api/tags`, {
+ method: 'GET',
+ headers: {
+ 'Origin': 'https://syui.ai',
+ }
+ })
+ return response.ok
+ } catch {
+ return false
+ }
+ }
+}
+
+/**
+ * Claude MCP Server Provider Implementation
+ */
+export class ClaudeMCPProvider extends AIProvider {
+ constructor(config) {
+ super(config)
+ this.endpoint = config.endpoint || 'https://your-server.com/api/claude-mcp'
+ this.apiKey = config.apiKey // Server-side auth token
+ this.systemPrompt = config.systemPrompt || ''
+ }
+
+ async ask(question, context = {}) {
+ const userInfo = context.userProfile
+ ? `相手の名前は${context.userProfile.displayName || context.userProfile.handle}です。`
+ : ''
+ const enhancedSystemPrompt = `${this.systemPrompt} ${userInfo}`
+
+ const controller = new AbortController()
+ const timeoutId = setTimeout(() => controller.abort(), 45000) // Longer timeout for Claude
+
+ try {
+ const response = await fetch(this.endpoint, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Authorization': `Bearer ${this.apiKey}`,
+ },
+ body: JSON.stringify({
+ question: question,
+ systemPrompt: enhancedSystemPrompt,
+ context: context
+ }),
+ signal: controller.signal
+ })
+
+ clearTimeout(timeoutId)
+
+ if (!response.ok) {
+ throw new Error(`Claude MCP error: ${response.status}`)
+ }
+
+ const data = await response.json()
+ return { answer: data.answer || 'エラーが発生しました' }
+
+ } catch (error) {
+ clearTimeout(timeoutId)
+ if (error.name === 'AbortError') {
+ throw new Error('Request timeout')
+ }
+ throw error
+ }
+ }
+
+ async healthCheck() {
+ try {
+ const response = await fetch(`${this.endpoint}/health`, {
+ method: 'GET',
+ headers: {
+ 'Authorization': `Bearer ${this.apiKey}`,
+ }
+ })
+ return response.ok
+ } catch {
+ return false
+ }
+ }
+}
+
+/**
+ * AI Provider Factory
+ */
+export class AIProviderFactory {
+ static create(provider, config) {
+ switch (provider) {
+ case 'ollama':
+ return new OllamaProvider(config)
+ case 'claude-mcp':
+ return new ClaudeMCPProvider(config)
+ default:
+ throw new Error(`Unknown AI provider: ${provider}`)
+ }
+ }
+
+ static createFromEnv() {
+ const provider = import.meta.env.VITE_AI_PROVIDER || 'ollama'
+
+ const config = {
+ systemPrompt: import.meta.env.VITE_AI_SYSTEM_PROMPT || '',
+ }
+
+ switch (provider) {
+ case 'ollama':
+ config.host = import.meta.env.VITE_AI_HOST
+ config.model = import.meta.env.VITE_AI_MODEL
+ break
+ case 'claude-mcp':
+ config.endpoint = import.meta.env.VITE_CLAUDE_MCP_ENDPOINT
+ config.apiKey = import.meta.env.VITE_CLAUDE_MCP_API_KEY
+ break
+ }
+
+ return AIProviderFactory.create(provider, config)
+ }
+}
\ No newline at end of file
diff --git a/ai-conversation/src/services/oauth.js b/ai-conversation/src/services/oauth.js
new file mode 100644
index 0000000..ea5155a
--- /dev/null
+++ b/ai-conversation/src/services/oauth.js
@@ -0,0 +1,182 @@
+import { BrowserOAuthClient } from '@atproto/oauth-client-browser'
+import { Agent } from '@atproto/api'
+import { env } from '../config/env.js'
+import { isSyuIsHandle } from '../utils/pds.js'
+
+export class OAuthService {
+ constructor() {
+ this.clientId = env.oauth.clientId || this.getClientId()
+ this.clients = { bsky: null, syu: null }
+ this.agent = null
+ this.sessionInfo = null
+ this.initPromise = null
+ }
+
+ getClientId() {
+ const origin = window.location.origin
+ return origin.includes('localhost') || origin.includes('127.0.0.1')
+ ? undefined // Loopback client
+ : `${origin}/client-metadata.json`
+ }
+
+ async initialize() {
+ if (this.initPromise) return this.initPromise
+
+ this.initPromise = this._initialize()
+ return this.initPromise
+ }
+
+ async _initialize() {
+ try {
+ // Initialize OAuth clients
+ this.clients.bsky = await BrowserOAuthClient.load({
+ clientId: this.clientId,
+ handleResolver: 'https://bsky.social',
+ plcDirectoryUrl: 'https://plc.directory',
+ })
+
+ this.clients.syu = await BrowserOAuthClient.load({
+ clientId: this.clientId,
+ handleResolver: 'https://syu.is',
+ plcDirectoryUrl: 'https://plc.syu.is',
+ })
+
+ // Try to restore session
+ return await this.restoreSession()
+ } catch (error) {
+ console.error('OAuth initialization failed:', error)
+ this.initPromise = null
+ throw error
+ }
+ }
+
+ async restoreSession() {
+ // Try both clients
+ for (const client of [this.clients.bsky, this.clients.syu]) {
+ const result = await client.init()
+ if (result?.session) {
+ this.agent = new Agent(result.session)
+ return this.processSession(result.session)
+ }
+ }
+ return null
+ }
+
+ async processSession(session) {
+ const did = session.sub || session.did
+ let handle = session.handle || 'unknown'
+ let displayName = null
+ let avatar = null
+
+ // Create Agent directly with session (per official docs)
+ try {
+ this.agent = new Agent(session)
+ } catch (err) {
+ // Fallback to dpopFetch method
+ this.agent = new Agent({
+ service: session.server?.serviceEndpoint || 'https://bsky.social',
+ fetch: session.dpopFetch
+ })
+ }
+
+ // Get profile information using authenticated agent
+ // Skip test DIDs
+ if (this.agent && did && !did.includes('test-')) {
+ try {
+ await new Promise(resolve => setTimeout(resolve, 300))
+ const profile = await this.agent.getProfile({ actor: did })
+ handle = profile.data.handle || handle
+ displayName = profile.data.displayName || null
+ avatar = profile.data.avatar || null
+
+ console.log('Profile fetched from session:', {
+ did,
+ handle,
+ displayName,
+ avatar: avatar ? 'present' : 'none'
+ })
+ } catch (error) {
+ console.log('Failed to get profile from session:', error)
+ // Keep the basic info we have
+ }
+ } else if (did && did.includes('test-')) {
+ console.log('Skipping profile fetch for test DID:', did)
+ }
+
+ this.sessionInfo = {
+ did,
+ handle,
+ displayName,
+ avatar
+ }
+
+ return {
+ did,
+ handle,
+ displayName,
+ avatar
+ }
+ }
+
+ async login(handle) {
+ await this.initialize()
+
+ const client = isSyuIsHandle(handle) ? this.clients.syu : this.clients.bsky
+ const authUrl = await client.authorize(handle, {
+ scope: 'atproto transition:generic'
+ })
+
+ window.location.href = authUrl.toString()
+ }
+
+ async checkAuth() {
+ try {
+ await this.initialize()
+ if (this.sessionInfo) {
+ return {
+ user: this.sessionInfo,
+ agent: this.agent
+ }
+ }
+ return null
+ } catch (error) {
+ console.error('Auth check failed:', error)
+ return null
+ }
+ }
+
+ async logout() {
+ try {
+ // Sign out from session
+ if (this.clients.bsky) {
+ const result = await this.clients.bsky.init()
+ if (result?.session?.signOut) {
+ await result.session.signOut()
+ }
+ }
+
+ // Clear state
+ this.agent = null
+ this.sessionInfo = null
+ this.clients = { bsky: null, syu: null }
+ this.initPromise = null
+
+ // Clear storage
+ localStorage.clear()
+ sessionStorage.clear()
+
+ // Reload page
+ window.location.reload()
+ } catch (error) {
+ console.error('Logout failed:', error)
+ }
+ }
+
+ getAgent() {
+ return this.agent
+ }
+
+ getUser() {
+ return this.sessionInfo
+ }
+}
\ No newline at end of file
diff --git a/ai-conversation/src/utils/avatar.js b/ai-conversation/src/utils/avatar.js
new file mode 100644
index 0000000..130c2d7
--- /dev/null
+++ b/ai-conversation/src/utils/avatar.js
@@ -0,0 +1,206 @@
+import React from 'react'
+import { atproto } from '../api/atproto.js'
+import { getPdsFromHandle, getApiConfig } from './pds.js'
+import { dataCache } from './cache.js'
+import { logError } from './errorHandler.js'
+
+// Cache duration for avatar URLs (30 minutes)
+const AVATAR_CACHE_DURATION = 30 * 60 * 1000
+
+/**
+ * Avatar fetching utility with fallback mechanism
+ *
+ * Strategy:
+ * 1. First check if avatar exists in the record
+ * 2. If avatar is missing/broken, fetch fresh data from ATProto
+ * 3. Cache results to avoid excessive API calls
+ */
+
+/**
+ * Extract avatar URL from record if available
+ * @param {Object} record - The record object
+ * @returns {string|null} Avatar URL or null
+ */
+function getAvatarFromRecord(record) {
+ const avatar = record?.value?.author?.avatar
+ if (avatar && typeof avatar === 'string' && avatar.startsWith('http')) {
+ return avatar
+ }
+ return null
+}
+
+/**
+ * Fetch fresh avatar data from ATProto
+ * @param {string} handle - User handle
+ * @param {string} did - User DID (optional, will fetch if not provided)
+ * @returns {Promise} Avatar URL or null
+ */
+async function fetchFreshAvatar(handle, did = null) {
+ try {
+ // Step 1: Get PDS from handle
+ const pds = await getPdsFromHandle(handle)
+ const apiConfig = getApiConfig(pds)
+
+ // Step 2: Get DID if not provided
+ if (!did) {
+ const pdsHost = pds.replace(/^https?:\/\//, '')
+ const repoData = await atproto.getDid(pdsHost, handle)
+ did = repoData
+ }
+
+ // Step 3: Get profile from bsky API
+ const profile = await atproto.getProfile(apiConfig.bsky, did)
+
+ // Return avatar URL
+ return profile?.avatar || null
+ } catch (error) {
+ logError(error, 'Avatar Fetch')
+ return null
+ }
+}
+
+/**
+ * Get avatar with intelligent fallback
+ * @param {Object} options - Options object
+ * @param {Object} options.record - Record object (optional)
+ * @param {string} options.handle - User handle (required if no record)
+ * @param {string} options.did - User DID (optional)
+ * @param {boolean} options.forceFresh - Force fresh fetch even if cached
+ * @returns {Promise} Avatar URL or null
+ */
+export async function getAvatar({ record, handle, did, forceFresh = false }) {
+ // Extract handle and DID from record if available
+ if (record && !handle) {
+ handle = record.value?.author?.handle
+ did = record.value?.author?.did
+ }
+
+ if (!handle) {
+ throw new Error('Handle is required to fetch avatar')
+ }
+
+ // Generate cache key
+ const cacheKey = `avatar:${handle}`
+
+ // Check cache first (unless forceFresh)
+ if (!forceFresh) {
+ const cached = dataCache.get(cacheKey)
+ if (cached) {
+ return cached
+ }
+ }
+
+ // Try to get avatar from record first
+ if (record) {
+ const recordAvatar = getAvatarFromRecord(record)
+ if (recordAvatar) {
+ // Validate that the avatar URL is still accessible
+ try {
+ const response = await fetch(recordAvatar, { method: 'HEAD' })
+ if (response.ok) {
+ dataCache.set(cacheKey, recordAvatar, AVATAR_CACHE_DURATION)
+ return recordAvatar
+ }
+ } catch {
+ // Avatar URL is broken, proceed to fetch fresh
+ }
+ }
+ }
+
+ // Fetch fresh avatar data
+ const freshAvatar = await fetchFreshAvatar(handle, did)
+
+ if (freshAvatar) {
+ dataCache.set(cacheKey, freshAvatar, AVATAR_CACHE_DURATION)
+ }
+
+ return freshAvatar
+}
+
+/**
+ * Batch fetch avatars for multiple users
+ * @param {Array