diff --git a/.github/workflows/cloudflare-pages.yml b/.github/workflows/cloudflare-pages.yml
index 995e8d2..fe82337 100644
--- a/.github/workflows/cloudflare-pages.yml
+++ b/.github/workflows/cloudflare-pages.yml
@@ -40,22 +40,6 @@ 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/Cargo.toml b/Cargo.toml
index 004d89d..d294a3f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "ailog"
-version = "0.2.8"
+version = "0.2.9"
edition = "2021"
authors = ["syui"]
description = "A static blog generator with AI features"
diff --git a/ai-conversation/.env b/ai-conversation/.env
deleted file mode 100644
index 801b270..0000000
--- a/ai-conversation/.env
+++ /dev/null
@@ -1,10 +0,0 @@
-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
deleted file mode 100644
index 1a90551..0000000
--- a/ai-conversation/.env.production
+++ /dev/null
@@ -1,19 +0,0 @@
-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
deleted file mode 100644
index 17f32e2..0000000
--- a/ai-conversation/build-minimal.js
+++ /dev/null
@@ -1,29 +0,0 @@
-// 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
deleted file mode 100644
index 4d281b5..0000000
--- a/ai-conversation/index.html
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
-
- 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
deleted file mode 100644
index afba414..0000000
--- a/ai-conversation/json/ai.syui.ai_chat_comment.json
+++ /dev/null
@@ -1,62 +0,0 @@
-{
- "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
deleted file mode 100644
index 20d6f1e..0000000
--- a/ai-conversation/json/ai.syui.ai_chat_lang.json
+++ /dev/null
@@ -1,62 +0,0 @@
-{
- "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
deleted file mode 100644
index 171c549..0000000
--- a/ai-conversation/json/ai.syui.ai_log.json
+++ /dev/null
@@ -1,30 +0,0 @@
-{
- "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
deleted file mode 100644
index d2b86b7..0000000
--- a/ai-conversation/json/ai.syui.ai_user.json
+++ /dev/null
@@ -1,53 +0,0 @@
-{
- "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
deleted file mode 100644
index 2f8fda0..0000000
--- a/ai-conversation/json/syui.syui.ai_chat.json
+++ /dev/null
@@ -1,53 +0,0 @@
-{
- "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
deleted file mode 100644
index aa38702..0000000
--- a/ai-conversation/json/syui.syui.ai_log.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "records": []
-}
diff --git a/ai-conversation/package.json b/ai-conversation/package.json
deleted file mode 100644
index d530ad0..0000000
--- a/ai-conversation/package.json
+++ /dev/null
@@ -1,22 +0,0 @@
-{
- "name": "ailog-ai-conversation",
- "version": "0.2.8",
- "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
deleted file mode 100644
index 79cc255..0000000
--- a/ai-conversation/src/App.css
+++ /dev/null
@@ -1,1383 +0,0 @@
-/* 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
deleted file mode 100644
index c412533..0000000
--- a/ai-conversation/src/App.jsx
+++ /dev/null
@@ -1,110 +0,0 @@
-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
deleted file mode 100644
index 12836ac..0000000
--- a/ai-conversation/src/api/atproto.js
+++ /dev/null
@@ -1,192 +0,0 @@
-// 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
deleted file mode 100644
index 76deb6a..0000000
--- a/ai-conversation/src/components/AskAI.jsx
+++ /dev/null
@@ -1,399 +0,0 @@
-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
deleted file mode 100644
index 4544854..0000000
--- a/ai-conversation/src/components/AuthButton.jsx
+++ /dev/null
@@ -1,77 +0,0 @@
-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
deleted file mode 100644
index 9f819cc..0000000
--- a/ai-conversation/src/components/Avatar.jsx
+++ /dev/null
@@ -1,234 +0,0 @@
-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
deleted file mode 100644
index 3e65e5b..0000000
--- a/ai-conversation/src/components/AvatarImage.jsx
+++ /dev/null
@@ -1,103 +0,0 @@
-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
deleted file mode 100644
index a48cebc..0000000
--- a/ai-conversation/src/components/AvatarTest.jsx
+++ /dev/null
@@ -1,203 +0,0 @@
-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
deleted file mode 100644
index 5206b5d..0000000
--- a/ai-conversation/src/components/AvatarTestPanel.jsx
+++ /dev/null
@@ -1,246 +0,0 @@
-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
deleted file mode 100644
index c8c9a95..0000000
--- a/ai-conversation/src/components/ChatRecordList.jsx
+++ /dev/null
@@ -1,194 +0,0 @@
-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
deleted file mode 100644
index 50176ac..0000000
--- a/ai-conversation/src/components/CommentForm.jsx
+++ /dev/null
@@ -1,134 +0,0 @@
-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
deleted file mode 100644
index fae85f3..0000000
--- a/ai-conversation/src/components/LoadingSkeleton.jsx
+++ /dev/null
@@ -1,98 +0,0 @@
-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
deleted file mode 100644
index bcab2e6..0000000
--- a/ai-conversation/src/components/OAuthCallback.jsx
+++ /dev/null
@@ -1,36 +0,0 @@
-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
deleted file mode 100644
index d43466f..0000000
--- a/ai-conversation/src/components/ProfileForm.jsx
+++ /dev/null
@@ -1,165 +0,0 @@
-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
deleted file mode 100644
index ea9124f..0000000
--- a/ai-conversation/src/components/ProfileRecordList.jsx
+++ /dev/null
@@ -1,136 +0,0 @@
-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
deleted file mode 100644
index 452fdb6..0000000
--- a/ai-conversation/src/components/RecordList.jsx
+++ /dev/null
@@ -1,154 +0,0 @@
-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
deleted file mode 100644
index 8a6de4d..0000000
--- a/ai-conversation/src/components/RecordTabs.jsx
+++ /dev/null
@@ -1,214 +0,0 @@
-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
deleted file mode 100644
index fe1d1c0..0000000
--- a/ai-conversation/src/components/TestUI.jsx
+++ /dev/null
@@ -1,531 +0,0 @@
-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
deleted file mode 100644
index c9d4354..0000000
--- a/ai-conversation/src/components/UserLookup.jsx
+++ /dev/null
@@ -1,115 +0,0 @@
-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
deleted file mode 100644
index eea1efa..0000000
--- a/ai-conversation/src/config/env.js
+++ /dev/null
@@ -1,17 +0,0 @@
-// 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
deleted file mode 100644
index ccc9767..0000000
--- a/ai-conversation/src/hooks/useAdminData.js
+++ /dev/null
@@ -1,135 +0,0 @@
-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
deleted file mode 100644
index 90a4974..0000000
--- a/ai-conversation/src/hooks/useAskAI.js
+++ /dev/null
@@ -1,230 +0,0 @@
-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
deleted file mode 100644
index df51dc1..0000000
--- a/ai-conversation/src/hooks/useAuth.js
+++ /dev/null
@@ -1,80 +0,0 @@
-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
deleted file mode 100644
index 4ca82de..0000000
--- a/ai-conversation/src/hooks/usePageContext.js
+++ /dev/null
@@ -1,33 +0,0 @@
-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
deleted file mode 100644
index 26ac3ae..0000000
--- a/ai-conversation/src/hooks/useUserData.js
+++ /dev/null
@@ -1,215 +0,0 @@
-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
deleted file mode 100644
index 5bf4036..0000000
--- a/ai-conversation/src/main.jsx
+++ /dev/null
@@ -1,10 +0,0 @@
-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
deleted file mode 100644
index 654bc2c..0000000
--- a/ai-conversation/src/services/aiProvider.js
+++ /dev/null
@@ -1,214 +0,0 @@
-/**
- * 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
deleted file mode 100644
index ea5155a..0000000
--- a/ai-conversation/src/services/oauth.js
+++ /dev/null
@@ -1,182 +0,0 @@
-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
deleted file mode 100644
index 130c2d7..0000000
--- a/ai-conversation/src/utils/avatar.js
+++ /dev/null
@@ -1,206 +0,0 @@
-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