77 Commits

Author SHA1 Message Date
cb8b0582e9 rm log 2025-08-01 21:25:52 +09:00
85494944ad rm log 2025-08-01 20:36:35 +09:00
5aeeba106a add post 2025-07-30 19:30:01 +09:00
f1e76ab31f fix post 2025-07-27 05:04:01 +09:00
3c9ef78696 add binary 2025-07-26 20:54:23 +09:00
ee2d21b0f3 update 2025-07-26 20:00:16 +09:00
0667ac58fb test game 2025-07-26 19:51:55 +09:00
d89855338b fix css 2025-07-18 10:57:42 +09:00
e19170cdff add pds.html 2025-07-18 00:05:04 +09:00
c3e22611f5 fix layout 2025-07-17 23:57:08 +09:00
2943c94ec1 binary 2025-07-17 22:23:14 +09:00
f27997b7e8 rm pds asset 2025-07-17 22:20:25 +09:00
447e4bded9 update 2025-07-17 22:12:06 +09:00
03161a52ca fix oauth-ai-chat 2025-07-17 19:26:40 +09:00
fe9381a860 fix blog post 2025-07-17 19:26:40 +09:00
f0cea89005 fix oauth filter 2025-07-16 22:57:09 +09:00
b059fe1de0 fix comment, rm console.log 2025-07-16 22:53:01 +09:00
07b0b0f702 fix css 2025-07-16 20:58:42 +09:00
ecd69557fe oauth markdown 2025-07-16 20:42:50 +09:00
452a0fda6a fix blog post 2025-07-16 11:47:15 +09:00
a62dd82790 fix config 2025-07-16 11:27:37 +09:00
3faec33bac fix blog post 2025-07-16 11:04:50 +09:00
33402f4a21 add blog post 2025-07-16 11:04:02 +09:00
3e65bc8210 binary 2025-07-16 10:18:03 +09:00
16d724ec25 update 2025-07-16 10:08:43 +09:00
69182a1bf8 update 2025-07-16 09:33:46 +09:00
0110773592 test ai-blog 2025-07-16 09:32:45 +09:00
75f108e7b8 fix blog post link 2025-07-14 15:27:10 +09:00
263189ce72 add blog post 2025-07-14 14:11:55 +09:00
7800a655f3 fix profile 2025-07-13 08:12:40 +09:00
76c797e4d8 add blog post 2025-07-13 07:52:43 +09:00
d1a1c92842 update binary 2025-07-11 13:38:22 +09:00
9da1f87640 fix update version 2025-07-11 13:09:15 +09:00
ddfc43512c add md msg 2025-07-11 08:52:34 +09:00
b3ccd61935 add my-blog msg 2025-07-11 08:51:46 +09:00
a243b6a44e fix post filename 2025-07-05 15:42:36 +09:00
e3c1cf4790 fix build err 2025-07-05 15:31:04 +09:00
a6236661bf post 2025-07-05 15:30:55 +09:00
195a4474c9 fix config.toml 2025-07-01 21:22:48 +09:00
4a34a6ca59 rm my-blog/oauth 2025-07-01 21:20:26 +09:00
4d01fb8507 fix oauth network err 2025-07-01 19:48:49 +09:00
d69c9aa09b update binary 2025-07-01 06:22:15 +09:00
99ee49f76e feat: add server-side image comparison shortcode support
- Add {{< img-compare >}} and [img-compare] shortcode syntax
- Implement server-side shortcode processing in Rust
- Create dedicated shortcode module for extensibility
- Fix image comparison slider display issues
- Remove caption display for cleaner UI
- Update to version 0.2.6

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-01 06:09:39 +09:00
19c0e28668 add post 2025-07-01 06:02:31 +09:00
bc99eb0814 update img-slider 2025-07-01 06:02:25 +09:00
cf93721bad fix social-app uri 2025-06-26 19:56:13 +09:00
8a8a121f4a fix delete record 2025-06-25 23:14:27 +09:00
be2bcae1d6 fix test ask-AI oauth profile 2025-06-25 23:03:50 +09:00
2c08a4acfb test blog profile 2025-06-25 21:18:13 +09:00
7791399314 fix claude-code proxy 2025-06-24 22:55:16 +09:00
26b1b2cf87 fix mobile css 2025-06-22 01:50:49 +09:00
7eb653f569 fix layout article.article-content 2025-06-22 01:16:59 +09:00
0fc920c844 fix layout 2025-06-22 00:35:54 +09:00
13c05d97d2 add claude-code-mcp-server 2025-06-22 00:26:20 +09:00
71acd44810 fix layout font-size 2025-06-22 00:25:44 +09:00
1b4579d0f1 fix layout font-size 2025-06-22 00:25:04 +09:00
09100f6d99 fix ask-ai prompt userdata 2025-06-22 00:01:27 +09:00
169de9064a fix link github 2025-06-21 19:11:01 +09:00
097c794623 fix oauth bsky button 2025-06-21 18:30:39 +09:00
b652e01dd3 fix oauth loading button 2025-06-21 17:03:23 +09:00
31af524303 fix layout 2025-06-21 15:46:21 +09:00
6be024864d cleanup docs 2025-06-21 00:07:22 +09:00
eef1fdad38 fix layout 2025-06-20 23:26:32 +09:00
b7e411e8b2 add img 2025-06-20 23:26:11 +09:00
8f9d803a94 fix gh-actions 2025-06-20 00:08:23 +09:00
f9b9c2ab52 fix layout 2025-06-20 00:00:51 +09:00
210ce801f1 update binary 2025-06-20 00:00:29 +09:00
6cb46f2ca1 fix token refresh 2025-06-19 23:01:41 +09:00
9406597b82 add post 2025-06-19 22:08:43 +09:00
0dbc3ba67e fix html text 2025-06-19 21:22:01 +09:00
a7e6fc4a1a Release v0.2.4: Complete OAuth system with AI chat and mobile support
- Fixed OAuth authentication with ATProto integration
- Implemented Ask AI functionality with conversation history
- Resolved PDS/web link issues for cross-network compatibility
- Added comprehensive mobile responsive design
- Enhanced comment posting with loading states and auto-refresh
- Improved chat record display with question/answer pairing
- Fixed tab scrolling and layout overflow issues

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-19 20:02:24 +09:00
3adcfdacf5 fix post commnet 2025-06-19 19:58:40 +09:00
004081337c fix ask-ai put 2025-06-19 19:52:31 +09:00
5ce0e0fd7a fix ask-ai 2025-06-19 19:18:50 +09:00
f816abb84f fix mobile css, ask-ai 2025-06-19 19:12:29 +09:00
8541af9293 add binary 2025-06-19 17:26:48 +09:00
68b49d5aaf Fix jetstream monitoring for ai.syui.log collections
- Fixed JetstreamMessage struct to correctly parse collection from commit object
- Fixed user list JSON format to match oauth app expectations (removed metadata field)
- Added monitoring for both ai.syui.log and ai.syui.log.chat.comment collections
- Improved error handling and debug output for stream processing
- Jetstream auto user registration now working correctly

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-19 17:14:11 +09:00
114 changed files with 7781 additions and 4036 deletions

View File

@@ -1,62 +0,0 @@
{
"permissions": {
"allow": [
"Bash(cargo init:*)",
"Bash(cargo:*)",
"Bash(find:*)",
"Bash(mkdir:*)",
"Bash(../target/debug/ailog new:*)",
"Bash(../target/debug/ailog build)",
"Bash(/Users/syui/ai/log/target/debug/ailog build)",
"Bash(ls:*)",
"Bash(curl:*)",
"Bash(pkill:*)",
"WebFetch(domain:docs.anthropic.com)",
"WebFetch(domain:github.com)",
"Bash(rm:*)",
"Bash(mv:*)",
"Bash(cp:*)",
"Bash(timeout:*)",
"Bash(grep:*)",
"Bash(./target/debug/ailog:*)",
"Bash(cat:*)",
"Bash(npm install)",
"Bash(npm run build:*)",
"Bash(chmod:*)",
"Bash(./scripts/tunnel.sh:*)",
"Bash(PRODUCTION=true cargo run -- build)",
"Bash(cloudflared tunnel:*)",
"Bash(npm install:*)",
"Bash(./scripts/build-oauth-partial.zsh:*)",
"Bash(./scripts/quick-oauth-update.zsh:*)",
"Bash(../target/debug/ailog serve)",
"Bash(./scripts/test-oauth.sh:*)",
"Bash(./run.zsh:*)",
"Bash(npm run dev:*)",
"Bash(./target/release/ailog:*)",
"Bash(rg:*)",
"Bash(../target/release/ailog build)",
"Bash(zsh run.zsh:*)",
"Bash(hugo:*)",
"WebFetch(domain:docs.bsky.app)",
"WebFetch(domain:syui.ai)",
"Bash(rustup target list:*)",
"Bash(rustup target:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git push:*)",
"Bash(git tag:*)",
"Bash(../bin/ailog:*)",
"Bash(../target/release/ailog oauth build:*)",
"Bash(ailog:*)",
"WebFetch(domain:plc.directory)",
"WebFetch(domain:atproto.com)",
"WebFetch(domain:syu.is)",
"Bash(sed:*)",
"Bash(./scpt/run.zsh:*)",
"Bash(RUST_LOG=debug cargo run -- stream status)",
"Bash(RUST_LOG=debug cargo run -- stream test-api)"
],
"deny": []
}
}

View File

@@ -41,6 +41,17 @@ jobs:
cp -rf ${{ env.OAUTH_DIR }}/dist/* my-blog/static/ cp -rf ${{ env.OAUTH_DIR }}/dist/* my-blog/static/
cp ${{ env.OAUTH_DIR }}/dist/index.html my-blog/templates/oauth-assets.html cp ${{ env.OAUTH_DIR }}/dist/index.html my-blog/templates/oauth-assets.html
- name: Build PDS app
run: |
cd pds
npm install
npm run build
- name: Copy PDS build to static
run: |
rm -rf my-blog/static/pds
cp -rf pds/dist my-blog/static/pds
- name: Cache ailog binary - name: Cache ailog binary
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
@@ -110,49 +121,49 @@ jobs:
gitHubToken: ${{ secrets.GITHUB_TOKEN }} gitHubToken: ${{ secrets.GITHUB_TOKEN }}
wranglerVersion: '3' wranglerVersion: '3'
# cleanup: cleanup:
# needs: deploy needs: deploy
# runs-on: ubuntu-latest runs-on: ubuntu-latest
# if: success() if: success()
# steps: steps:
# - name: Cleanup old deployments - name: Cleanup old deployments
# run: | run: |
# curl -X PATCH \ curl -X PATCH \
# "https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${{ secrets.CLOUDFLARE_PROJECT_NAME }} \ "https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${{ secrets.CLOUDFLARE_PROJECT_NAME }}" \
# -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \ -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \
# -H "Content-Type: application/json") -H "Content-Type: application/json" \
# -d "{ \"deployment_configs\": { \"production\": { \"deployment_retention\": ${{ env.KEEP_DEPLOYMENTS }} } } }" -d "{ \"deployment_configs\": { \"production\": { \"deployment_retention\": ${{ env.KEEP_DEPLOYMENTS }} } } }"
# # Get all deployments # Get all deployments
# DEPLOYMENTS=$(curl -s -X GET \ DEPLOYMENTS=$(curl -s -X GET \
# "https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${{ secrets.CLOUDFLARE_PROJECT_NAME }}/deployments" \ "https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${{ secrets.CLOUDFLARE_PROJECT_NAME }}/deployments" \
# -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \ -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \
# -H "Content-Type: application/json") -H "Content-Type: application/json")
#
# # Extract deployment IDs (skip the latest N deployments) # Extract deployment IDs (skip the latest N deployments)
# DEPLOYMENT_IDS=$(echo "$DEPLOYMENTS" | jq -r ".result | sort_by(.created_on) | reverse | .[${{ env.KEEP_DEPLOYMENTS }}:] | .[].id // empty") DEPLOYMENT_IDS=$(echo "$DEPLOYMENTS" | jq -r ".result | sort_by(.created_on) | reverse | .[${{ env.KEEP_DEPLOYMENTS }}:] | .[].id // empty")
#
# if [ -z "$DEPLOYMENT_IDS" ]; then if [ -z "$DEPLOYMENT_IDS" ]; then
# echo "No old deployments to delete" echo "No old deployments to delete"
# exit 0 exit 0
# fi fi
#
# # Delete old deployments # Delete old deployments
# for ID in $DEPLOYMENT_IDS; do for ID in $DEPLOYMENT_IDS; do
# echo "Deleting deployment: $ID" echo "Deleting deployment: $ID"
# RESPONSE=$(curl -s -X DELETE \ RESPONSE=$(curl -s -X DELETE \
# "https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${{ secrets.CLOUDFLARE_PROJECT_NAME }}/deployments/$ID" \ "https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${{ secrets.CLOUDFLARE_PROJECT_NAME }}/deployments/$ID" \
# -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \ -H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \
# -H "Content-Type: application/json") -H "Content-Type: application/json")
#
# SUCCESS=$(echo "$RESPONSE" | jq -r '.success') SUCCESS=$(echo "$RESPONSE" | jq -r '.success')
# if [ "$SUCCESS" = "true" ]; then if [ "$SUCCESS" = "true" ]; then
# echo "Successfully deleted deployment: $ID" echo "Successfully deleted deployment: $ID"
# else else
# echo "Failed to delete deployment: $ID" echo "Failed to delete deployment: $ID"
# echo "$RESPONSE" | jq . echo "$RESPONSE" | jq .
# fi fi
#
# sleep 1 # Rate limiting sleep 1 # Rate limiting
# done done
#
# echo "Cleanup completed!" echo "Cleanup completed!"

7
.gitignore vendored
View File

@@ -10,13 +10,18 @@ dist
node_modules node_modules
package-lock.json package-lock.json
my-blog/static/assets/comment-atproto-* my-blog/static/assets/comment-atproto-*
my-blog/static/ai-assets/comment-atproto-*
bin/ailog bin/ailog
docs docs
my-blog/static/index.html my-blog/static/index.html
my-blog/templates/oauth-assets.html my-blog/templates/oauth-assets.html
cloudflared-config.yml cloudflared-config.yml
.config .config
atproto repos
oauth_old oauth_old
oauth_example oauth_example
my-blog/static/oauth/assets/comment-atproto* my-blog/static/oauth/assets/comment-atproto*
*.lock
my-blog/config.toml
.claude/settings.local.json
my-blog/static/pds

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "ailog" name = "ailog"
version = "0.2.2" version = "0.3.1"
edition = "2021" edition = "2021"
authors = ["syui"] authors = ["syui"]
description = "A static blog generator with AI features" description = "A static blog generator with AI features"
@@ -39,6 +39,8 @@ urlencoding = "2.1"
axum = "0.7" axum = "0.7"
tower = "0.5" tower = "0.5"
tower-http = { version = "0.5", features = ["cors", "fs"] } tower-http = { version = "0.5", features = ["cors", "fs"] }
axum-extra = { version = "0.9", features = ["typed-header"] }
tracing = "0.1"
hyper = { version = "1.0", features = ["full"] } hyper = { version = "1.0", features = ["full"] }
tower-sessions = "0.12" tower-sessions = "0.12"
jsonwebtoken = "9.2" jsonwebtoken = "9.2"
@@ -54,6 +56,8 @@ tokio-tungstenite = { version = "0.21", features = ["rustls-tls-webpki-roots", "
futures-util = "0.3" futures-util = "0.3"
tungstenite = { version = "0.21", features = ["rustls-tls-webpki-roots"], default-features = false } tungstenite = { version = "0.21", features = ["rustls-tls-webpki-roots"], default-features = false }
rpassword = "7.3" rpassword = "7.3"
rustyline = "14.0"
dirs = "5.0"
[dev-dependencies] [dev-dependencies]
tempfile = "3.14" tempfile = "3.14"

Binary file not shown.

View File

@@ -16,13 +16,76 @@ auto_translate = false
comment_moderation = false comment_moderation = false
ask_ai = true ask_ai = true
provider = "ollama" provider = "ollama"
model = "qwen3" model = "gemma3"
model_translation = "llama3.2:1b" host = "localhost:11434"
model_technical = "phi3:mini"
host = "http://localhost:11434"
system_prompt = "あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。" system_prompt = "あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"
handle = "ai.syui.ai" handle = "ai.syui.ai"
#num_predict = 200
[ai.profiles]
[ai.profiles.user]
did = "did:plc:vzsvtbtbnwn22xjqhcu3vd6y"
handle = "syui.syui.ai"
display_name = "syui"
avatar_url = "https://bsky.syu.is/img/avatar/plain/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/bafkreif62mqyra4ndv6ohlscl7adp3vhalcjxwhs676ktfj2sq2drs3pdi@jpeg"
profile_url = "https://syu.is/profile/did:plc:vzsvtbtbnwn22xjqhcu3vd6y"
[ai.profiles.ai]
did = "did:plc:6qyecktefllvenje24fcxnie"
handle = "ai.syui.ai"
display_name = "ai"
avatar_url = "https://bsky.syu.is/img/avatar/plain/did:plc:6qyecktefllvenje24fcxnie/bafkreigo3ucp32carhbn3chfc3hlf6i7f4rplojc76iylihzpifyexi24y@jpeg"
profile_url = "https://syu.is/profile/did:plc:6qyecktefllvenje24fcxnie"
[ai.templates]
fallback = """なるほど!面白い話題だね!
{question}
アイが思うに、この手の技術って急速に進歩してるから、具体的な製品名とか実例を交えて話した方が分かりやすいかもしれないの!
最近だと、AI関連のツールやプロトコルがかなり充実してきてて、実用レベルのものが増えてるんだよ
アイは宇宙とかAIとか、難しい話も知ってるから、特にどんな角度から深掘りしたいの実装面それとも将来的な可能性とかアイと一緒に考えよう"""
[[ai.templates.responses]]
keywords = ["ゲーム", "game", "npc", "NPC"]
priority = 1
template = """わあゲームの話だねアイ、ゲームのAIってすっごく面白いと思う
{question}
アイが知ってることだと、最近のゲームはNPCがお話できるようになってるんだって**Inworld AI**っていうのがUE5で使えるようになってるし、**Unity Muse**も{current_year}年から本格的に始まってるんだよ!
アイが特に面白いと思うのは、**MCP**っていうのを使うと:
- GitHub MCPでゲームのファイル管理ができる
- Weather MCPでリアルタイムのお天気が連動する
- Slack MCPでチーム開発が効率化される
スタンフォードの研究では、ChatGPTベースのAI住民が自分で街を作って生活してるのを見たことがあるの数年後にはNPCの概念が根本的に変わりそうで、わくわくしちゃう
UE5への統合、どんな機能から試したいのアイも一緒に考えたい"""
[[ai.templates.responses]]
keywords = ["AI", "ai", "MCP", "mcp"]
priority = 1
template = """AIとMCPの話アイの得意分野だよ
{question}
{current_year}年の状況だと、MCP市場が拡大してて、実用的なサーバーが数多く使えるようになってるの
アイが知ってる開発系では:
- **GitHub MCP**: PR作成とリポジトリ管理が自動化
- **Docker MCP**: コンテナ操作をAIが代行
- **PostgreSQL MCP**: データベース設計・最適化を支援
クリエイティブ系では:
- **Blender MCP**: 3Dモデリングの自動化
- **Figma MCP**: デザインからコード変換
**Zapier MCP**なんて数千のアプリと連携できるから、もう手作業でやってる場合じゃないよね!
アイは小さい物質のことも知ってるから、どの分野でのMCP活用を考えてるのか教えて具体的なユースケースがあると、もっと詳しくお話できるよ"""
[oauth] [oauth]
json = "client-metadata.json" json = "client-metadata.json"
@@ -31,3 +94,30 @@ admin = "ai.syui.ai"
collection = "ai.syui.log" collection = "ai.syui.log"
pds = "syu.is" pds = "syu.is"
handle_list = ["syui.syui.ai", "ai.syui.ai", "ai.ai"] handle_list = ["syui.syui.ai", "ai.syui.ai", "ai.ai"]
[blog]
base_url = "https://syui.ai"
content_dir = "./my-blog/content/posts"
[profiles]
[profiles.user]
handle = "syui.syui.ai"
did = "did:plc:vzsvtbtbnwn22xjqhcu3vd6y"
display_name = "syui"
avatar_url = "https://bsky.syu.is/img/avatar/plain/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/bafkreif62mqyra4ndv6ohlscl7adp3vhalcjxwhs676ktfj2sq2drs3pdi@jpeg"
profile_url = "https://syu.is/profile/did:plc:vzsvtbtbnwn22xjqhcu3vd6y"
[profiles.ai]
handle = "ai.syui.ai"
did = "did:plc:6qyecktefllvenje24fcxnie"
display_name = "ai"
avatar_url = "https://bsky.syu.is/img/avatar/plain/did:plc:6qyecktefllvenje24fcxnie/bafkreigo3ucp32carhbn3chfc3hlf6i7f4rplojc76iylihzpifyexi24y@jpeg"
profile_url = "https://syu.is/profile/did:plc:6qyecktefllvenje24fcxnie"
[paths]
claude_paths = [
"/Users/syui/.claude/local/claude",
"claude",
"/usr/local/bin/claude",
"/opt/homebrew/bin/claude"
]

View File

@@ -155,3 +155,21 @@ fn main() {
console.log("Hello, world!"); console.log("Hello, world!");
``` ```
## msg
[msg type="info" content="これは情報メッセージです。重要な情報を読者に伝えるために使用します。"]
{{< msg type="warning" content="これは警告メッセージです。注意が必要な情報を示します。" >}}
[msg type="error" content="これはエラーメッセージです。問題やエラーを示します。"]
{{< msg type="success" content="これは成功メッセージです。操作が成功したことを示します。" >}}
[msg type="note" content="これはノートメッセージです。補足情報や備考を示します。"]
[msg content="これはデフォルトメッセージです。タイプが指定されていない場合、自動的に情報メッセージとして表示されます。"]
## img-compare
[img-compare before="/img/ue_blender_model_ai_v0401.png" after="/img/ue_blender_model_ai_v0501.png" width="800" height="300"]

View File

@@ -0,0 +1,78 @@
---
title: "oauthに対応した"
slug: "oauth"
date: 2025-06-19
tags: ["atproto"]
draft: false
---
現在、[syu.is](https://syu.is)に[atproto](https://github.com/bluesky-social/atproto)をselfhostしています。
oauthを`bsky.social`, `syu.is`ともに動くようにしました。
![](/img/atproto_oauth_syuis.png)
ここでいうselfhostは、pds, plc, bsky, bgsなどを自前のserverで動かし、連携することをいいいます。
ちなみに、atprotoは[bluesky](https://bsky.app)のようなものです。
ただし、その内容は結構複雑で、`at://did`の仕組みで動くsnsです。
usernameは`handle`という`domain`の形を採用しています。
didの名前解決(dns)をしているのが`plc`です。`pds`はuserのdataを保存しています。timelineに配信したり表示しているのが`bsky(appview)`, 統合しているのが`bgs`です。
その他、`social-app`がclientで、`ozone`がmoderationです。
```sh
"6qyecktefllvenje24fcxnie" -> "ai.syu.is"
```
## oauthでハマったところ
現在、`bsky.team`のpds, plc, bskyには`did:plc:6qyecktefllvenje24fcxnie`が登録されています。これは`syu.is``@ai.syui.ai`のアカウントです。
```sh
$ did=did:plc:6qyecktefllvenje24fcxnie
$ curl -sL https://plc.syu.is/$did|jq .alsoKnownAs
[ "at://ai.syui.ai" ]
$ curl -sL https://plc.directory/$did|jq .alsoKnownAs
[ "at://ai.syu.is" ]
```
しかし、みて分かる通り、bskyではhandle-changeが反映されていますが、pds, plcは`@ai.syu.is`で登録されており、更新されていないようです。
```sh
$ handle=ai.syui.ai
$ curl -sL "https://syu.is/xrpc/com.atproto.identity.resolveHandle?handle=$handle" | jq -r .did
did:plc:6qyecktefllvenje24fcxnie
$ curl -sL "https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=$handle" | jq -r .did
null
$ curl -sL "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=$handle" | jq -r .did
did:plc:6qyecktefllvenje24fcxnie
```
[msg type="warning" content="現在はbsky.teamのpdsにhandle-changeが反映されています。"]
oauthは、そのままではbsky.teamのpds, plcを使って名前解決を行います。この場合、まず、それらのserverにdidが登録されている必要があります。
次に、handleの更新が反映されている必要があります。もし反映されていない場合、handleとpasswordが一致しません。
localhostではhandleをdidにすることで突破できそうでしたが、本番環境では難しそうでした。
なお、[@atproto/oauth-provider](https://github.com/bluesky-social/atproto/tree/main/packages/oauth/oauth-provider)の本体を書き換えて、pdsで使うと回避は可能だと思います。
私の場合は、その方法は使わず、didの名前解決には自前のpds, plcを使用することにしました。
```js
this.oauthClient = await BrowserOAuthClient.load({
clientId: this.getClientId(),
handleResolver: pdsUrl,
plcDirectoryUrl: pdsUrl === 'https://syu.is' ? 'https://plc.syu.is' : 'https://plc.directory',
});
```

View File

@@ -0,0 +1,40 @@
---
title: "world system v0.2"
slug: "ue"
date: 2025-06-30
tags: ["ue", "blender"]
draft: false
---
最近のゲーム開発の進捗です。
## world system
現在、ue5.6で新しく世界を作り直しています。
これは、ゲーム開発のproject内でworld systemという名前をつけた惑星形式のmapを目指す領域になります。
現在、worldscape + udsで理想に近い形のmapができました。ただ、問題もたくさんあり、重力システムと天候システムです。
```sh
[issue]
1. 天候システム
2. 重力システム
```
ですが、今までのworld systemは、大気圏から宇宙に移行する場面や陸地が存在しない点、地平線が不完全な点などがありましたが、それらの問題はすべて解消されました。
```sh
[update]
1. 大気圏から宇宙に移行する場面が完全になった
2. 陸地ができた
3. 地平線が完全なアーチを描けるように
4. 月、惑星への着陸ができるようになった
5. 横から惑星に突入できるようになった
```
面白い動画ではありませんが、現状を記録しておきます。
<iframe width="100%" height="415" src="https://www.youtube.com/embed/K0solfQAQTQ?si=B6qD-WUODTUpWZ0y" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

View File

@@ -0,0 +1,80 @@
---
title: "aiosを作り直した"
slug: "aios"
date: 2025-07-05
tags: ["os"]
draft: false
---
`aios`とは自作osのことで、archlinuxをベースにしていました。
```sh
#!/bin/zsh
git clone https://gitlab.archlinux.org/archlinux/archiso
cp -rf ./cfg/profiledef.sh ./archiso/configs/releng/profiledef.sh
cp -rf ./cfg/profiledef.sh ./archiso/configs/baseline/profiledef.sh
cp -rf ./scpt/mkarchiso ./archiso/archiso/mkarchiso
./archiso/archiso/mkarchiso -v -o ./ ./archiso/configs/releng/
tar xf aios-bootstrap*.tar.gz
mkdir -p root.x86_64/var/lib/machines/arch
pacstrap -c root.x86_64/var/lib/machines/arch base
echo -e 'Server = http://mirrors.cat.net/archlinux/$repo/os/$arch
Server = https://geo.mirror.pkgbuild.com/$repo/os/$arch' >> ./root.x86_64/etc/pacman.d/mirrorlist
sed -i s/CheckSpace/#CheckeSpace/ root.x86_64/etc/pacman.conf
arch-chroot root.x86_64 /bin/sh -c 'pacman-key --init'
arch-chroot root.x86_64 /bin/sh -c 'pacman-key --populate archlinux'
arch-chroot root.x86_64 /bin/sh -c 'pacman -Syu --noconfirm base base-devel linux'
tar -zcvf aios-bootstrap.tar.gz root.x86_64/
```
```sh:./cfg/profiledef.sh
#!/usr/bin/env bash
# shellcheck disable=SC2034
iso_name="aios"
iso_label="AI_$(date --date="@${SOURCE_DATE_EPOCH:-$(date +%s)}" +%Y%m)"
iso_publisher="ai os <https://git.syui.ai/ai/os>"
iso_application="ai os Live/Rescue DVD"
iso_version="$(date --date="@${SOURCE_DATE_EPOCH:-$(date +%s)}" +%Y.%m.%d)"
install_dir="ai"
#buildmodes=('iso')
buildmodes=('bootstrap')
bootmodes=('bios.syslinux.mbr' 'bios.syslinux.eltorito'
'uefi-ia32.grub.esp' 'uefi-x64.grub.esp'
'uefi-ia32.grub.eltorito' 'uefi-x64.grub.eltorito')
arch="x86_64"
pacman_conf="pacman.conf"
airootfs_image_type="squashfs"
airootfs_image_tool_options=('-comp' 'xz' '-Xbcj' 'x86' '-b' '1M' '-Xdict-size' '1M')
file_permissions=(
["/etc/shadow"]="0:0:400"
["/root"]="0:0:750"
["/root/.automated_script.sh"]="0:0:755"
["/root/.gnupg"]="0:0:700"
["/usr/local/bin/choose-mirror"]="0:0:755"
["/usr/local/bin/Installation_guide"]="0:0:755"
["/usr/local/bin/livecd-sound"]="0:0:755"
)
```
## rust + unix
一からosを作りたいと思っていたので、rustでunixのosを作り始めました。
![](/img/aios_v0201.png)
名前は`Aios`にして、今回は`syui`のprojectとして作り始めました。
後に`ai/os`と統合するかもしれません。
1. [https://git.syui.ai/ai/os](https://git.syui.ai/ai/os)
```sh
#!/bin/zsh
d=${0:a:h:h}
cd $d/kernel
cargo bootimage --release
BOOT_IMAGE="../target/x86_64-unknown-none/release/bootimage-aios-kernel.bin"
qemu-system-x86_64 -drive format=raw,file="$BOOT_IMAGE"
```

View File

@@ -0,0 +1,114 @@
---
title: "yui system v0.2.1"
slug: "blender"
date: 2025-07-11
tags: ["blender", "ue", "vmc"]
draft: false
---
`yui system`をupdateしました。別名、`unique system`ともいい、プレイヤーの唯一性を担保するためのもので、キャラクターのモデルもここで管理します。
今回は、blenderでモデルを作り直している話になります。
## blenderで作るvrm
モデルをblenderで作り直すことにしました。
vroidからblenderに移行。blenderでmodelを作る作業はとても大変でした。
今回は、素体と衣装を別々に作り組み合わせています。完成度の高いモデルをいくつか参考にしています。
materialも分離したため、ue5で指定しやすくなりました。これによって変身時にue5のmaterialを指定しています。eyeのmaterialを分離して色を付けています。
![](/img/ue_blender_model_ai_v0604.png)
## modelの変遷
[img-compare before="/img/ue_blender_model_ai_v0601.png" after="/img/ue_blender_model_ai_v0602.png" width="800" height="300"]
[msg type="info" content="v0.1: vroidからblenderへ移行。blenderは初めてなので簡単なことだけ実行。"]
[img-compare before="/img/ue_blender_model_ai_v0602.png" after="/img/ue_blender_model_ai_v0603.png" width="800" height="300"]
[msg type="info" content="v0.2: blenderの使い方を次の段階へシフト。最初から作り直す。様々な問題が発生したが、大部分を解消した。"]
しかし、まだまだ問題があり、細かな調整が必要です。
[msg type="error" content="衣装同士、あるいは体が多少すり抜ける事がある。ウェイトペイントやボーンの調整が完璧ではない。"]
## eyeが動かない問題を解決
`vmc`で目玉であるeyeだけ動かないことに気づいて修正しました。
`eye`の部分だけvroid(vrm)のboneを使うことで解決できました。しかし、新たにblenderかvrm-addonのbugに遭遇しました。具体的にはboneがxyz軸で動かせなくなるbugです。これは不定期で発生していました。boneを動かせるときと動かせなくなるときがあり、ファイルは同じものを使用。また、スクリプト画面ではboneを動かせます。
## 指先がうまく動かない問題を解決
vmcで指先の動きがおかしくなるので、ウェイトペイントを塗り直すと治りました。
## worldscapeで足が浮いてしまう問題を解決
worldscapeでは陸地に降り立つとプレイヤーが浮いてしまいます。
gaspのabpでfoot placementを外す必要がありました。これは、モデルの問題ではなく、gaspのキャラクターすべてで発生します。
ここの処理を削除します。
<iframe src="https://blueprintue.com/render/wrrxz9vm" scrolling="no" allowfullscreen style="width:100%;height:400px"></iframe>
## 衣装のガビガビを解決
昔からあった衣装のガビガビは重複する面を削除することで解消できました。
```md
全選択A キー)
Mesh → Clean Up → Merge by Distance
距離を0.000にして実行
```
## materialの裏表を解決
これはue5で解消したほうがいいでしょう。編集していると、面の裏表の管理が面倒なことがあります。
materialで`Two Sided`を有効にします。
## キャラクターのエフェクトを改良
これらの処理を簡略化できました。最初は雑に書いていましたが、vrmは何度も修正し、上書きされますから、例えば、`SK_Mesh`でmaterialを設定する方法はよくありません。
<iframe src="https://blueprintue.com/render/gue0vayu" scrolling="no" allowfullscreen style="width:100%;height:400px"></iframe>
## gameplay camera pluginをue5.6に対応
ue5.5と5.6では関数も他の処理も変わっていて、rotationを`BP_Player`でsetすると、crashするbugがあります。
基本的には、`Blueprints/Cameras/CameraRigPrefab_BasicThiredPersonBehavior`をみてください。
<iframe src="https://blueprintue.com/render/-e0r7oxq" scrolling="no" allowfullscreen style="width:100%;height:400px"></iframe>
![](https://git.syui.ai/attachments/019d2079-1450-4271-8816-ded92f60b3c9)
キャラクターが動く場合は、`Update Rotation Pre CMC`にある`Use Controller Desired Rotation`, `Orient Rotation To Movement`の処理です。両方を`true`にしましょう。
`vmc`時もこれで対処します。
## gaspでidle, sprintをオリジナルに変更
これはabpで設定します。設定方法はue5.5と変わりません。
[https://ue-book.syui.ai/gasp/11_run.html](https://ue-book.syui.ai/gasp/11_run.html)
## vrm4uのvmcに対応
まず、clientはwabcam motion captureが最も自然に動作しています。
[msg type="warning" content="これは1年くらい前の検証結果です。現在はもっとよいvmc clientの選択肢があるかもしれません。"]
次に、`ABP_Pose_$NAME`が作られますが、vrmはよく更新しますので、`SK_Mesh`でcustom ABPを指定すると楽でしょう。
![](https://git.syui.ai/attachments/758407eb-5e77-4876-830b-ba4a78884e8d)
## youtube
<iframe width="100%" height="420" src="https://www.youtube.com/embed/qggHtmkMIko?vq=hd1080&rel=0&showinfo=0&controls=0" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

View File

@@ -0,0 +1,36 @@
---
title: "yui system v0.2.2"
slug: "blender2"
date: 2025-07-11
tags: ["blender", "ue", "vmc"]
draft: false
---
新しい問題を発見したので、それらを解消しました。
## wingがbodyに入り込んでしまう
wingとmodelは分離させています。衣装の着せ替えを簡単にできるようにすること。それが新しく作ったblender modelの方針でした。
ただ、調整が難しくなったのも事実で、例えば、colliderの調整ができません。これによってbodyに入り込んでしまうことが多くなりました。
これは、とりあえず、wingのcolliderやboneを追加すること、そして、modelのneckに変更することで解消しました。
ただし、この方法も完璧ではないかもしれません。
## vmcではwingが追従しない
modelと分離しているので、vmc時には追従しません。したがって、wingのabpでmodelと同じvmcを入れます。これで解消できました。
## vrmでcustom abpを使用するとueがcrashする
vrm4uで`.vrm`をimportすると`SK_$NAME`にcustom abpを設定していた場合はueがcrashします。
上書きimportするならこれをnone(clear)に変更します。
## modelの頭身を調整
比較画像を出した際に、少しmodelのバランスが悪かったので調整しました。
具体的には、髪の毛を少し下げました。

View File

@@ -0,0 +1,155 @@
---
title: "自作ゲームのsystemを説明する"
slug: "game"
date: 2025-07-12
tags: ["ue"]
draft: false
---
現在、自作ゲームを開発しています。
このゲームには4つの柱があり、それらはsystemで分けられています。そして、systemは根本的な2つの価値観に基づきます。
根本的な2つの価値観は、(1)現実を反映すること、(2)この世界に同じものは一つもないという唯一性になります。
1. 現実の反映
2. 唯一性の担保
では、各systemについて説明していきます。
# system
## world system
別名、planet systemといいます。
現実の反映という価値観から、ゲーム世界もできる限り現実に合わせようと思いworld systemを作っています。
ゲームは通常、平面世界です。これはゲームエンジンのルールであり、基本的にゲーム世界は平面をベースにしています。
ですから、例えば、上に行っても、下に行っても、あるいは右に行っても、左に行っても、ずっと地平線が広がっています。
しかし、現実世界では、上に行けば、やがて大気圏を越え、宇宙に出ます。
最初は昔から認知されていた地球、月、太陽という3つの星を現実に合わせて作りました。
そして、マップをできる限り惑星形式にします。
これは非常に難しいことで、現在もいくつか問題を抱えています。
ただし、このworld systemの問題がゲームプレイに影響するかと言われると、殆どの場合、影響しません。ゲームプレイの領域は、最初は非常に狭い範囲で作ろうと思っています。小さなところから完璧に作っていきたいという思いがあります。
つまり、プレイヤーは空にも宇宙にも到達できません。それが見えるかどうかもわかりません。しかし、見えない部分もしっかりと作り、世界があるということが私にとって大切です。
まずは、狭いけど完璧な空間を作り、そこでゲームシステムを完成させます。広い世界はできる限り見えないようにしたほうがいいでしょう。夢の世界のような狭い空間を作り、そこでシンプルで小さいゲームができます。もちろん、広い世界に出ることはできません。そもそもこのゲーム、見えない部分をちゃんと作る、そこにも世界がちゃんとあるというのをテーマにしているので、広い世界で何かをやるようなゲームを目指していなかったりします。なにかのときに垣間見える、かもしれない外の世界、広い世界。それを感じられることがある、ということ。それが重要なので、このsystem自体は背景に過ぎないのです。
最初から広い世界があるのではなく`狭い世界 -> 広い世界`への移行が重要だと考えています。この移行に関しては、演出というテーマに基づき、設計する必要があります。それがゲームとしての面白さを作る、ということなのだと思います。
## yui system
別名、unique systemといいます。プレイヤーの唯一性を担保するためのsystemです。
とはいえ、色々なものがここに詰め込まれるでしょう。characterのモデリングとかもそうですね。
どのように担保していくかは未定ですが、いくつか案があります。配信との連携、vmcでモーションキャプチャなどを考えていました。
## ai system
別名、ability systemといいます。
主に、ゲーム性に関することです。ゲーム性とはなにか。それは、永続するということです。
例えば、将棋やオセロを考えてみてください。無限の組み合わせがあり、可能であればずっと遊んでいられる。そのような仕組みを目指します。
まずは属性を物語から考えます。物語は最も小さい物質の探求です。アクシオンやバリオンなどの架空の物質、そして、中性子や原子などの現実の物質が属性となり、1キャラクターにつき1属性を持ちます。
## at system
別名、account systemといいます。
プレイヤーが現実のアカウントを使用してプレイできることを目指します。`atproto`を採用して、ゲームデータを個人のアカウントが所有することを目指しています。
# 現実の反映とはなにか
わかり易い言葉で「現実の反映」を目指すと言いましたが、これはどういうことでしょう。
私の中では「同一性」とも言い換えられます。
例えば、現実の世界とゲームの世界があるのではなく「すべてが現実である」という考え方をします。言い換えると「すべて同じもの」ということ。
もし多くの人が現実世界とゲーム世界を別物と捉えているなら、できる限りその認識を壊す方向で考えます。
例えば、`at system`では現実のsnsアカウントをゲームアカウントに使用したり、現実の出来事をゲームに反映したり、またはゲームの出来事を現実に反映する仕組みを考えます。
全ては一つ、一つはすべて。
同一性と唯一性は一見して矛盾しますが、その統合を考えます。
# 物語と実装
```md
# 物語-存在
同一性
唯一性
# system-実装
world system
yui system
ai system
at system
```
物語では、この世界のものは全て存在であると説きます。存在しかない世界。存在だけがある世界。そして、あらゆる存在を構築しているこの世界で最も小さいものが「存在子」です。存在子は別名、アイといいます。そして、このアイにも同じものはありません。すべての存在子は異なるもの、別の意識。
アイは、最初に生まれたキャラクターとして、アイ属性を扱います。これらの設定は`ai system`の領域です。アイは自分のことをアイと呼びます。
> アイは、この世界と一緒だからね。同じものは一つもないよ。
# どこまで実装できた
実は、上記のsystemは既にすべてを実装したことがあります。
```md
[at system]
ゲームが始まると、atprotoのaccountでloginでき、取得したアイテムなどはatproto(pds)に保存されます。
[ai system]
キャラクターは属性攻撃ができます。
[world system]
上へ上へと飛んでいけば、雲を超え、宇宙空間に出られます。
[yui system]
配信環境やvmcでキャラクターを動かすことができます。
```
しかし、ue5.5で作っていたsystemも、ue5.6にupdateすると全て動かなくなりました。また一から作り直しています。私は、モデルの作り方から、ゲームの作り方まで初心者ですから、何度も作り直すことで、ゲーム作りを覚えられます。
そして、まだ革新的なアイディアを見つけられていません。それはシンプルで身近にあり、人々が面白いと思うもの。まだゲームになっていない、あるいはあまり知られていないものである必要があります。
例えば、ウマ娘でいうと競馬、ポケモンでいうと捕獲、になります。
それを見つけ、ゲームに取り込む事ができれば完成と言えるでしょう。
そして、ゲームに取り込むことが複雑で難しすぎるようなものではありません。シンプルで単純でわかりやすいものでなければなりません。
## versionを付ける
そろそろversionを付けるかどうか迷っています。
今までモヤモヤしていたものが、最近はよりはっきりしてきたと感じます。ただ、versionはあまり覚えていないし、付ける意味もない。これまではそうでした。
もしかすると今もそうかもしれません。色々なものがバラバラで管理しきれないのです。
ですが、今までやってきたことを総合すると、現在は、`v0.2`くらいだと思います。
最初、はじめてueを触ったときに宇宙マップを使って構築しました。これをv0.0としましょう。
次に、city sampleと宇宙を統合しました。これがv0.1です。
最近はworldscapeを使ってマップを構築しています。これがv0.2です。
aiというキャラクターモデルの変遷も大体を3つの段階に分けられると思います。初めてモデルを作った、vroidで作ったのがv0.0、blenderを初めて触ったのがv0.1、現在がv0.2です。
とはいえ、この設定もそのうち忘れ、どこかで圧縮されてしまうかもしれませんが、覚えているならここから徐々にversionが上がっていくでしょう。

View File

@@ -0,0 +1,48 @@
---
title: "chromeからfirefoxに移行した"
slug: "firefox"
date: 2025-07-14
tags: ["chrome", "firefox", "browser"]
draft: false
---
AIから勧められたのでchromeからfirefoxに移行しました。
chromeにとどまっていた理由は、翻訳機能です。
しかし、firefoxにも翻訳機能betaが来ていて、日本語が翻訳できるようになっていました。
[https://support.mozilla.org/ja/kb/website-translation](https://support.mozilla.org/ja/kb/website-translation)
chromeからの移行理由は、主に[gorhill/ublock](https://github.com/gorhill/ublock)です。
## chromeを使い続ける方法
私はfirefoxに移行しましたが、いくつか回避策があります。
`chrome://flags`でいくつかの機能のenable, disableを切り替えます。
```json
{
"url": "chrome://flags",
"purpose": "Maintain Manifest V2 extension support",
"versions": {
"138": {
"enabled": [
"Temporarily unexpire M137 flags",
"Allow legacy extension manifest versions"
],
"disabled": [
"Extension Manifest V2 Deprecation Warning Stage",
"Extension Manifest V2 Deprecation Disabled Stage",
"Extension Manifest V2 Deprecation Unsupported Stage"
]
},
"139": {
"enabled": [
"Temporarily unexpired M138 flags"
]
}
}
}
```

View File

@@ -0,0 +1,10 @@
---
title: "ゲームとAI制御"
slug: "6bf4b020"
date: "2025-07-16"
tags: ["ai", "conversation"]
draft: false
extra:
type: "ai"
---

View File

@@ -0,0 +1,40 @@
---
title: "AIとの会話をブログにする"
slug: "ailog"
date: "2025-07-16"
tags: ["blog", "rust", "atproto"]
draft: false
---
今後、ブログはどのように書かれるようになるのでしょう。今回はその事を考えていきます。
結論として、AIとの会話をそのままブログにするのが一番なのではないかと思います。つまり、自分で書く場合と、AIとの会話をブログにする場合のハイブリッド型です。
ブログを書くのは面倒で、AIの出力、情報に劣ることもよくあります。実際、AIとの会話をそのままブログにしたいことが増えました。
とはいえ、情報の価値は思想よりも低いと思います。
多くの人がブログに求めるのは著者の思想ではないでしょうか。
`思想 > 情報`
したがって、AIを使うにしても、それが表現されていなければなりません。
## ailogの新機能
このことから、以下のような流れでブログを生成する仕組みを作りました。これは`ailog`の機能として実装し、`ailog`という単純なコマンドですべて処理されます。
```sh
$ ailog
```
1. 著者の思想をAIに質問する
2. 著者が作ったAIキャラクターが質問に答える
3. その会話をatprotoに投稿する
4. その会話をblogに表示する
とはいえ、会話は`claude`を使用します。依存関係が多すぎて汎用的な仕組みではありません。
これを汎用的な仕組みで作る場合、repositoryを分離して新しく作る必要があるでしょう。
example: [/posts/2025-07-16-6bf4b020.html](/posts/2025-07-16-6bf4b020.html)

View File

@@ -0,0 +1,64 @@
---
title: "ue5のgaspとdragonikを組み合わせてenemyを作る"
slug: "gasp-dragonik-enemy-chbcharacter"
date: "2025-07-30"
tags: ["ue"]
draft: false
---
ue5.6でgasp(game animation sample project)をベースにゲーム、特にキャラクターの操作を作っています。
そして、enemy(敵)を作り、バトルシーンを作成する予定ですが、これはどのように開発すればいいのでしょう。その方針を明確にします。
1. enemyもgaspの`cbp_character`に統合し、自キャラ、敵キャラどちらでも使用可能にする
2. 2番目のcharacterは動物型(type:animal)にし、gaspに統合する
3. enemyとして使用する場合は、enemy-AI-componentを追加するだけで完結する
4. characterのすべての操作を統一する
このようにすることで、応用可能なenemyを作ることができます。
例えば、`2番目のcharacterは動物型(type:animal)にする`というのはどういうことでしょう。
登場するキャラクターを人型(type:human), 動物型(type:animal)に分けるとして、動物型のテンプレートを作る必要があります。そのまま動物のmeshをgaspで使うと動きが変になってしまうので、それを調整する必要があるということ。そして、調整したものをテンプレート化して、他の動物にも適用できるようにしておくと、後の開発は楽ですよね。
ですから、早いうちにtype:humanから脱却し、他のtypeを作るほうがいいと判断しました。
これには、`dragon ik plugin`を使って、手っ取り早く動きを作ります。
`characterのすべての操作を統一する`というのは、1キャラにつき1属性、1通常攻撃、1スキル、1バースト、などのルールを作り、それらを共通化することです。共通化というのは、playerもenemy-AI-componentも違うキャラを同じ操作で使用できることを指します。
## 2番目のキャラクター
原作には、西洋ドラゴンのドライ(drai)というキャラが登場します。その父親が東洋ドラゴンのシンオウ(shin-oh)です。これをshinという名前で登録し、2番目のキャラクターとして設定しました。
3d-modelは今のところue5のcrsp(control rig sample project)にあるchinese dragonを使用しています。後に改造して原作に近づけるようにしたいところですが、今は時間が取れません。
<iframe width="100%" height="415" src="https://www.youtube.com/embed/3c3Q1Z5r7QI" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
## データ構造の作成と適用
ゲームデータはatproto collection recordに保存して、そこからゲームに反映させたいと考えています。
まず基本データを`ai.syui.ai`のアカウントに保存。個別データをplayerのatprotoアカウントに保存する形が理想です。
基本データは、ゲームの基本的な設定のこと。例えば、キャラクターの名前や属性、スキルなど変更されることがない値。
個別データは、プレイヤーが使えるキャラ、レベル、攻撃力など、ゲームの進行とともに変更される値です。
ゲームをスタートさせると、まず基本データを取得し、それを`cbp_character`に適用します。ログインすると、`cbp_character`の変数(var)に値が振り分けられます。例えば、`skill-damage:0.0`があったとして、この値が変わります。
しかし、ゲームを開発していると、基本データも個別データも構造が複雑になります。
それを防ぐため、`{simple, core} mode`のような考え方を取り入れます。必要最小限の構成を分離、保存して、それをいつでも統合、適用できるように設計しておきます。
## gaspとdragonikを統合する方法
では、いよいよgaspとdragonikの統合手法を解説します。
まず、abpを作ります。それにdragonikを当て、それをSKM_Dragonのpost process animに指定します。
![](/img/ue_gasp_dragonik_shin_v0001.png)
次に、動きに合わせて首を上下させます。
<iframe src="https://blueprintue.com/render/piiw14oz" scrolling="no" allowfullscreen style="width:100%;height:400px"></iframe>

View File

@@ -0,0 +1,14 @@
{{- $type := .Get "type" | default "info" -}}
{{- $content := .Get "content" -}}
<div class="msg msg-{{ $type }}">
<div class="msg-icon">
{{- if eq $type "info" -}}
{{- else if eq $type "warning" -}}⚠️
{{- else if eq $type "error" -}}❌
{{- else if eq $type "success" -}}✅
{{- else if eq $type "note" -}}📝
{{- else -}}
{{- end -}}
</div>
<div class="msg-content">{{ $content | markdownify }}</div>
</div>

View File

@@ -1,20 +0,0 @@
# Production environment variables
VITE_APP_HOST=https://syui.ai
VITE_OAUTH_CLIENT_ID=https://syui.ai/client-metadata.json
VITE_OAUTH_REDIRECT_URI=https://syui.ai/oauth/callback
# Handle-based Configuration (DIDs resolved at runtime)
VITE_ATPROTO_PDS=syu.is
VITE_ADMIN_HANDLE=ai.syui.ai
VITE_AI_HANDLE=ai.syui.ai
VITE_OAUTH_COLLECTION=ai.syui.log
VITE_ATPROTO_WEB_URL=https://bsky.app
VITE_ATPROTO_HANDLE_LIST=["syui.syui.ai", "ai.syui.ai", "ai.ai"]
# AI Configuration
VITE_AI_ENABLED=true
VITE_AI_ASK_AI=true
VITE_AI_PROVIDER=ollama
VITE_AI_MODEL=qwen3
VITE_AI_HOST=http://localhost:11434
VITE_AI_SYSTEM_PROMPT="あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"

345
my-blog/static/css/pds.css Normal file
View File

@@ -0,0 +1,345 @@
@import url('./style.css');
.pds-container {
}
.pds-header {
text-align: center;
margin-bottom: 40px;
}
.pds-header h1 {
font-size: 2.5em;
margin-bottom: 10px;
color: #333;
}
.pds-search-section {
border-radius: 8px;
}
.pds-search-form {
display: flex;
justify-content: center;
padding: 0px 20px;
}
.form-group {
display: flex;
align-items: center;
}
.form-group input {
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px 0 0 4px;
font-size: 14px;
width: 600px;
outline: none;
transition: box-shadow 0.2s, border-color 0.2s;
}
.form-group input:focus {
border-color: var(--theme-color, #f40);
}
.form-group button {
padding: 9px 15px;
background: #1976d2;
color: white;
border: none;
border-radius: 0 4px 4px 0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
}
.form-group button:hover {
background: #1565c0;
}
/*
.user-info {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
*/
.user-profile {
display: flex;
align-items: center;
gap: 15px;
}
.user-details h3 {
margin: 0 0 5px 0;
color: #333;
}
.user-details p {
margin: 0;
color: #666;
}
.user-did-section {
margin: 20px 0;
}
.did-display {
padding: 10px;
background: #f5f5f5;
border-radius: 4px;
font-family: monospace;
font-size: 14px;
color: #666;
word-break: break-all;
margin-bottom: 10px;
}
.handle-display {
padding: 8px 10px;
background: #f0f9f0;
border-radius: 4px;
font-size: 13px;
color: #555;
margin-bottom: 8px;
}
.handle-display strong {
color: #2e7d32;
}
.handle-display span {
font-family: monospace;
font-size: 12px;
color: #666;
word-break: break-all;
}
.pds-display {
padding: 8px 10px;
background: #e8f4f8;
border-radius: 4px;
font-size: 13px;
color: #555;
}
.pds-display strong {
color: #1976d2;
}
.pds-display span {
font-family: monospace;
font-size: 12px;
color: #666;
word-break: break-all;
}
.collections-section,
.records-section {
margin: 20px 0;
}
.collections-section h3,
.records-section h3 {
font-size: 1.2em;
margin-bottom: 15px;
color: #333;
font-weight: bold;
}
.collections-list,
.records-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.at-uri-link {
display: block;
padding: 8px 12px;
background: #f9f9f9;
border-radius: 4px;
border: 1px solid #e0e0e0;
color: #1976d2;
text-decoration: none;
font-family: monospace;
font-size: 14px;
word-break: break-all;
transition: all 0.2s;
}
.at-uri-link:hover {
background: #e8f4f8;
border-color: #1976d2;
text-decoration: none;
}
.pds-info {
padding: 8px 12px;
background: #f0f9ff;
border-radius: 4px;
border: 1px solid #b3e5fc;
margin-bottom: 8px;
color: #1976d2;
font-size: 12px;
}
.collection-info {
padding: 8px 12px;
background: #f0f9f0;
border-radius: 4px;
border: 1px solid #b3e5b3;
margin-bottom: 8px;
color: #2e7d32;
font-size: 12px;
}
.collections-header {
margin-bottom: 10px;
}
.collections-toggle {
background: #f5f5f5;
border: 1px solid #ddd;
border-radius: 4px;
padding: 8px 12px;
cursor: pointer;
font-size: 14px;
color: #333;
transition: background-color 0.2s;
}
.collections-toggle:hover {
background: #e8f4f8;
border-color: #1976d2;
}
.pds-test-section,
.pds-about-section {
margin-bottom: 40px;
}
.pds-test-section h2,
.pds-about-section h2 {
font-size: 1.8em;
margin-bottom: 20px;
color: #333;
border-bottom: 2px solid #1976d2;
padding-bottom: 10px;
}
.test-uris {
display: flex;
flex-direction: column;
gap: 10px;
}
.at-uri {
background: #f5f5f5;
padding: 15px;
border-radius: 8px;
font-family: monospace;
font-size: 14px;
word-break: break-all;
cursor: pointer;
transition: background-color 0.2s;
border: 1px solid #e0e0e0;
}
.at-uri:hover {
background: #e8f4f8;
border-color: #1976d2;
}
.pds-about-section ul {
list-style-type: none;
padding: 0;
}
.pds-about-section li {
padding: 5px 0;
color: #666;
}
/* AT URI Modal Styles */
.at-uri-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.at-uri-modal-content {
background-color: white;
border-radius: 8px;
max-width: 800px;
max-height: 600px;
width: 90%;
height: 80%;
overflow: auto;
position: relative;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.at-uri-modal-close {
position: absolute;
top: 10px;
right: 10px;
background: none;
border: none;
font-size: 20px;
cursor: pointer;
z-index: 1001;
padding: 5px 10px;
}
/* Loading states */
.loading {
text-align: center;
padding: 20px;
color: #666;
}
.error {
text-align: center;
padding: 20px;
color: #d32f2f;
background: #ffeaea;
border-radius: 4px;
margin: 10px 0;
}
/* Responsive design */
@media (max-width: 768px) {
.pds-search-section {
display: none;
}
.pds-search-form {
flex-direction: column;
align-items: stretch;
}
.form-group {
align-items: stretch;
}
.form-group input {
width: 100%;
margin-bottom: 10px;
}
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 723 KiB

View File

@@ -5,6 +5,22 @@
// Global variables for AI functionality // Global variables for AI functionality
let aiProfileData = null; let aiProfileData = null;
// Get config from window or use defaults
const OAUTH_PDS = window.OAUTH_CONFIG?.pds || 'syu.is';
const ADMIN_HANDLE = window.OAUTH_CONFIG?.admin || 'ai.syui.ai';
const OAUTH_COLLECTION = window.OAUTH_CONFIG?.collection || 'ai.syui.log';
// Listen for AI profile data from OAuth app
window.addEventListener('aiProfileLoaded', function(event) {
aiProfileData = event.detail;
updateAskAIButton();
});
// Check if AI profile data is already available
if (window.aiProfileData) {
aiProfileData = window.aiProfileData;
}
// Original functions from working implementation // Original functions from working implementation
function toggleAskAI() { function toggleAskAI() {
const panel = document.getElementById('askAiPanel'); const panel = document.getElementById('askAiPanel');
@@ -12,24 +28,82 @@ function toggleAskAI() {
panel.style.display = isVisible ? 'none' : 'block'; panel.style.display = isVisible ? 'none' : 'block';
if (!isVisible) { if (!isVisible) {
checkAuthenticationStatus();
// If AI profile data is already available, show introduction immediately
if (aiProfileData) {
// Quick check for authentication
const userSections = document.querySelectorAll('.user-section');
const isAuthenticated = userSections.length > 0;
handleAuthenticationStatus(isAuthenticated);
return;
}
// For production fallback - if OAuth app fails to load, show profiles
const isProd = window.location.hostname !== 'localhost' && !window.location.hostname.includes('preview');
if (isProd) {
// Shorter timeout for production
setTimeout(() => {
const userSections = document.querySelectorAll('.user-section');
if (userSections.length === 0) {
handleAuthenticationStatus(false);
} else {
handleAuthenticationStatus(true);
}
}, 300);
} else {
checkAuthenticationStatus();
}
} }
} }
function checkAuthenticationStatus() { function checkAuthenticationStatus() {
const userSections = document.querySelectorAll('.user-section'); // Check multiple times for OAuth app to load
const isAuthenticated = userSections.length > 0; let checkCount = 0;
const maxChecks = 10;
const checkForAuth = () => {
const userSections = document.querySelectorAll('.user-section');
const authButtons = document.querySelectorAll('[data-auth-status]');
const oauthContainers = document.querySelectorAll('#oauth-container');
const isAuthenticated = userSections.length > 0;
if (isAuthenticated || checkCount >= maxChecks - 1) {
handleAuthenticationStatus(isAuthenticated);
} else {
checkCount++;
setTimeout(checkForAuth, 200);
}
};
checkForAuth();
}
function handleAuthenticationStatus(isAuthenticated) {
// Always hide loading first
document.getElementById('authCheck').style.display = 'none';
if (isAuthenticated) { if (isAuthenticated) {
// User is authenticated - show Ask AI UI // User is authenticated - show Ask AI UI
document.getElementById('authCheck').style.display = 'none';
document.getElementById('chatForm').style.display = 'block'; document.getElementById('chatForm').style.display = 'block';
document.getElementById('chatHistory').style.display = 'block'; document.getElementById('chatHistory').style.display = 'block';
// Show initial greeting if chat history is empty // Show initial greeting if chat history is empty and AI profile is available
const chatHistory = document.getElementById('chatHistory'); const chatHistory = document.getElementById('chatHistory');
if (chatHistory.children.length === 0) { if (chatHistory.children.length === 0) {
showInitialGreeting(); if (aiProfileData) {
showInitialGreeting();
} else {
// Wait for AI profile data
setTimeout(() => {
if (aiProfileData) {
showInitialGreeting();
}
}, 500);
}
} }
// Focus on input // Focus on input
@@ -37,10 +111,78 @@ function checkAuthenticationStatus() {
document.getElementById('aiQuestion').focus(); document.getElementById('aiQuestion').focus();
}, 50); }, 50);
} else { } else {
// User not authenticated - show auth message // User not authenticated - show AI introduction directly if profile available
document.getElementById('authCheck').style.display = 'block';
document.getElementById('chatForm').style.display = 'none'; document.getElementById('chatForm').style.display = 'none';
document.getElementById('chatHistory').style.display = 'none'; document.getElementById('chatHistory').style.display = 'block';
if (aiProfileData) {
// Show AI introduction directly using available profile data
showAIIntroduction();
} else {
// Fallback to profile loading
loadAndShowProfiles();
}
}
}
// Load and display profiles from ai.syui.log.profile collection
async function loadAndShowProfiles() {
const chatHistory = document.getElementById('chatHistory');
chatHistory.innerHTML = '<div class="loading-message">Loading profiles...</div>';
try {
const response = await fetch(`https://${OAUTH_PDS}/xrpc/com.atproto.repo.listRecords?repo=${ADMIN_HANDLE}&collection=${OAUTH_COLLECTION}&limit=100`);
if (!response.ok) {
throw new Error('Failed to fetch profiles');
}
const data = await response.json();
// Filter only profile records and sort
const profileRecords = (data.records || []).filter(record => record.value.type === 'profile');
const profiles = 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;
});
// Clear loading message
chatHistory.innerHTML = '';
// Display profiles using the same format as chat
profiles.forEach(profile => {
const profileDiv = document.createElement('div');
profileDiv.className = 'chat-message ai-message comment-style';
const avatarElement = profile.value.author.avatar
? `<img src="${profile.value.author.avatar}" alt="${profile.value.author.displayName || profile.value.author.handle}" class="profile-avatar">`
: `<div class="profile-avatar-fallback">${(profile.value.author.displayName || profile.value.author.handle || '?').charAt(0).toUpperCase()}</div>`;
const adminBadge = profile.value.profileType === 'admin'
? '<span class="admin-badge">Admin</span>'
: '';
profileDiv.innerHTML = `
<div class="message-header">
<div class="avatar">${avatarElement}</div>
<div class="user-info">
<div class="display-name">${profile.value.author.displayName || profile.value.author.handle} ${adminBadge}</div>
<div class="handle"><a href="https://${OAUTH_PDS}/profile/${profile.value.author.handle}" target="_blank" rel="noopener noreferrer">@${profile.value.author.handle}</a></div>
</div>
</div>
<div class="message-content">${profile.value.text}</div>
`;
chatHistory.appendChild(profileDiv);
});
if (profiles.length === 0) {
chatHistory.innerHTML = '<div class="no-profiles">No profiles available</div>';
}
} catch (error) {
chatHistory.innerHTML = '<div class="error-message">Failed to load profiles. Please try again later.</div>';
} }
} }
@@ -68,7 +210,6 @@ function askQuestion() {
})); }));
} catch (error) { } catch (error) {
console.error('Failed to ask question:', error);
showErrorMessage('Sorry, I encountered an error. Please try again.'); showErrorMessage('Sorry, I encountered an error. Please try again.');
} finally { } finally {
askButton.disabled = false; askButton.disabled = false;
@@ -107,8 +248,7 @@ function addUserMessage(question) {
<div class="avatar">${userAvatar}</div> <div class="avatar">${userAvatar}</div>
<div class="user-info"> <div class="user-info">
<div class="display-name">${userDisplay}</div> <div class="display-name">${userDisplay}</div>
<div class="handle">@${userHandle}</div> <div class="handle"><a href="https://${OAUTH_PDS}/profile/${userHandle}" target="_blank" rel="noopener noreferrer">@${userHandle}</a></div>
<div class="timestamp">${new Date().toLocaleString()}</div>
</div> </div>
</div> </div>
<div class="message-content">${question}</div> <div class="message-content">${question}</div>
@@ -171,17 +311,57 @@ function showInitialGreeting() {
<div class="avatar">${avatarElement}</div> <div class="avatar">${avatarElement}</div>
<div class="user-info"> <div class="user-info">
<div class="display-name">${aiProfileData.displayName}</div> <div class="display-name">${aiProfileData.displayName}</div>
<div class="handle">@${aiProfileData.handle}</div> <div class="handle"><a href="https://${OAUTH_PDS}/profile/${aiProfileData.handle}" target="_blank" rel="noopener noreferrer">@${aiProfileData.handle}</a></div>
<div class="timestamp">${new Date().toLocaleString()}</div>
</div> </div>
</div> </div>
<div class="message-content"> <div class="message-content">Hello! I'm an AI assistant trained on this blog's content. I can answer questions about the articles, provide insights, and help you understand the topics discussed here. What would you like to know?</div>
Hello! I'm an AI assistant trained on this blog's content. I can answer questions about the articles, provide insights, and help you understand the topics discussed here. What would you like to know?
</div>
`; `;
chatHistory.appendChild(greetingDiv); chatHistory.appendChild(greetingDiv);
} }
function showAIIntroduction() {
if (!aiProfileData) return;
const chatHistory = document.getElementById('chatHistory');
chatHistory.innerHTML = ''; // Clear any existing content
// AI Introduction message
const introDiv = document.createElement('div');
introDiv.className = 'chat-message ai-message comment-style initial-greeting';
const avatarElement = aiProfileData.avatar
? `<img src="${aiProfileData.avatar}" alt="${aiProfileData.displayName}" class="profile-avatar">`
: '🤖';
introDiv.innerHTML = `
<div class="message-header">
<div class="avatar">${avatarElement}</div>
<div class="user-info">
<div class="display-name">${aiProfileData.displayName}</div>
<div class="handle"><a href="https://${OAUTH_PDS}/profile/${aiProfileData.handle}" target="_blank" rel="noopener noreferrer">@${aiProfileData.handle}</a></div>
</div>
</div>
<div class="message-content">Hello! I'm an AI assistant trained on this blog's content. I can answer questions about the articles, provide insights, and help you understand the topics discussed here. What would you like to know?</div>
`;
chatHistory.appendChild(introDiv);
// OAuth login message
const loginDiv = document.createElement('div');
loginDiv.className = 'chat-message user-message comment-style initial-greeting';
loginDiv.innerHTML = `
<div class="message-header">
<div class="avatar">${avatarElement}</div>
<div class="user-info">
<div class="display-name">${aiProfileData.displayName}</div>
<div class="handle"><a href="https://${OAUTH_PDS}/profile/${aiProfileData.handle}" target="_blank" rel="noopener noreferrer">@${aiProfileData.handle}</a></div>
</div>
</div>
<div class="message-content">Please atproto oauth login</div>
`;
chatHistory.appendChild(loginDiv);
}
function updateAskAIButton() { function updateAskAIButton() {
const button = document.getElementById('askAiButton'); const button = document.getElementById('askAiButton');
if (!button) return; if (!button) return;
@@ -201,7 +381,6 @@ function handleAIResponse(responseData) {
const aiProfile = responseData.aiProfile; const aiProfile = responseData.aiProfile;
if (!aiProfile || !aiProfile.handle || !aiProfile.displayName) { if (!aiProfile || !aiProfile.handle || !aiProfile.displayName) {
console.error('AI profile data is missing');
return; return;
} }
@@ -217,8 +396,7 @@ function handleAIResponse(responseData) {
<div class="avatar">${avatarElement}</div> <div class="avatar">${avatarElement}</div>
<div class="user-info"> <div class="user-info">
<div class="display-name">${aiProfile.displayName}</div> <div class="display-name">${aiProfile.displayName}</div>
<div class="handle">@${aiProfile.handle}</div> <div class="handle"><a href="https://${OAUTH_PDS}/profile/${aiProfile.handle}" target="_blank" rel="noopener noreferrer">@${aiProfile.handle}</a></div>
<div class="timestamp">${timestamp.toLocaleString()}</div>
</div> </div>
</div> </div>
<div class="message-content">${responseData.answer}</div> <div class="message-content">${responseData.answer}</div>
@@ -244,7 +422,6 @@ function setupAskAIEventListeners() {
// Listen for AI profile updates from OAuth app // Listen for AI profile updates from OAuth app
window.addEventListener('aiProfileLoaded', function(event) { window.addEventListener('aiProfileLoaded', function(event) {
aiProfileData = event.detail; aiProfileData = event.detail;
console.log('AI profile loaded:', aiProfileData);
updateAskAIButton(); updateAskAIButton();
}); });
@@ -256,7 +433,6 @@ function setupAskAIEventListeners() {
// Listen for OAuth callback completion from iframe // Listen for OAuth callback completion from iframe
window.addEventListener('message', function(event) { window.addEventListener('message', function(event) {
if (event.data.type === 'oauth_success') { if (event.data.type === 'oauth_success') {
console.log('Received OAuth success message:', event.data);
// Close any OAuth popups/iframes // Close any OAuth popups/iframes
const oauthFrame = document.getElementById('oauth-frame'); const oauthFrame = document.getElementById('oauth-frame');
@@ -305,7 +481,36 @@ function setupAskAIEventListeners() {
// Initialize Ask AI when DOM is loaded // Initialize Ask AI when DOM is loaded
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
setupAskAIEventListeners(); setupAskAIEventListeners();
console.log('Ask AI initialized successfully');
// Also listen for OAuth app load completion
const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.type === 'childList') {
// Check if user-section was added/removed
const userSectionAdded = Array.from(mutation.addedNodes).some(node =>
node.nodeType === Node.ELEMENT_NODE &&
(node.classList?.contains('user-section') || node.querySelector?.('.user-section'))
);
const userSectionRemoved = Array.from(mutation.removedNodes).some(node =>
node.nodeType === Node.ELEMENT_NODE &&
(node.classList?.contains('user-section') || node.querySelector?.('.user-section'))
);
if (userSectionAdded || userSectionRemoved) {
// Update Ask AI panel if it's visible
const panel = document.getElementById('askAiPanel');
if (panel && panel.style.display !== 'none') {
checkAuthenticationStatus();
}
}
}
});
});
observer.observe(document.body, {
childList: true,
subtree: true
});
}); });
// Global functions for onclick handlers // Global functions for onclick handlers

View File

@@ -0,0 +1,123 @@
/**
* Image Comparison Slider
* UE5-style before/after image comparison component
*/
class ImageComparison {
constructor(container) {
this.container = container;
this.slider = container.querySelector('.slider');
this.beforeImg = container.querySelector('.img-before');
this.afterImg = container.querySelector('.img-after');
this.sliderThumb = container.querySelector('.slider-thumb');
this.isDragging = false;
this.containerRect = null;
this.init();
}
init() {
this.bindEvents();
this.updatePosition(50); // Start at 50%
}
bindEvents() {
// Mouse events
this.slider.addEventListener('input', (e) => {
this.updatePosition(e.target.value);
});
this.slider.addEventListener('mousedown', () => {
this.isDragging = true;
document.body.style.userSelect = 'none';
});
document.addEventListener('mouseup', () => {
if (this.isDragging) {
this.isDragging = false;
document.body.style.userSelect = '';
}
});
// Touch events for mobile
this.slider.addEventListener('touchstart', (e) => {
this.isDragging = true;
e.preventDefault();
});
this.slider.addEventListener('touchmove', (e) => {
if (this.isDragging) {
const touch = e.touches[0];
this.containerRect = this.container.getBoundingClientRect();
const x = touch.clientX - this.containerRect.left;
const percentage = Math.max(0, Math.min(100, (x / this.containerRect.width) * 100));
this.slider.value = percentage;
this.updatePosition(percentage);
e.preventDefault();
}
});
this.slider.addEventListener('touchend', () => {
this.isDragging = false;
});
// Direct click on container
this.container.addEventListener('click', (e) => {
if (e.target === this.container || e.target.classList.contains('img-comparison-slider')) {
this.containerRect = this.container.getBoundingClientRect();
const x = e.clientX - this.containerRect.left;
const percentage = Math.max(0, Math.min(100, (x / this.containerRect.width) * 100));
this.slider.value = percentage;
this.updatePosition(percentage);
}
});
// Keyboard support
this.slider.addEventListener('keydown', (e) => {
let value = parseFloat(this.slider.value);
switch (e.key) {
case 'ArrowLeft':
value = Math.max(0, value - 1);
break;
case 'ArrowRight':
value = Math.min(100, value + 1);
break;
case 'Home':
value = 0;
break;
case 'End':
value = 100;
break;
default:
return;
}
e.preventDefault();
this.slider.value = value;
this.updatePosition(value);
});
}
updatePosition(percentage) {
const position = parseFloat(percentage);
// Update clip-path for before image to show only the left portion
this.beforeImg.style.clipPath = `inset(0 ${100 - position}% 0 0)`;
// Update slider thumb position
this.sliderThumb.style.left = `${position}%`;
this.sliderThumb.style.transform = `translateX(-50%)`;
}
}
// Auto-initialize all image comparison components
document.addEventListener('DOMContentLoaded', function() {
const comparisons = document.querySelectorAll('.img-comparison-container');
comparisons.forEach(container => {
new ImageComparison(container);
});
});
// Export for manual initialization
window.ImageComparison = ImageComparison;

370
my-blog/static/js/pds.js Normal file
View File

@@ -0,0 +1,370 @@
// AT Protocol API functions
const AT_PROTOCOL_CONFIG = {
primary: {
pds: 'https://syu.is',
plc: 'https://plc.syu.is',
bsky: 'https://bsky.syu.is',
web: 'https://web.syu.is'
},
fallback: {
pds: 'https://bsky.social',
plc: 'https://plc.directory',
bsky: 'https://public.api.bsky.app',
web: 'https://bsky.app'
}
};
// Search user function
async function searchUser() {
const handleInput = document.getElementById('handleInput');
const userInfo = document.getElementById('userInfo');
const collectionsList = document.getElementById('collectionsList');
const recordsList = document.getElementById('recordsList');
const searchButton = document.getElementById('searchButton');
const input = handleInput.value.trim();
if (!input) {
alert('Handle nameまたはAT URIを入力してください');
return;
}
searchButton.disabled = true;
searchButton.innerHTML = '@';
//searchButton.innerHTML = '<i class="fab fa-bluesky"></i>';
try {
// Clear previous results
document.getElementById('userDidSection').style.display = 'none';
document.getElementById('collectionsSection').style.display = 'none';
document.getElementById('recordsSection').style.display = 'none';
collectionsList.innerHTML = '';
recordsList.innerHTML = '';
// Check if input is AT URI
if (input.startsWith('at://')) {
// Parse AT URI to check if it's a full record or just a handle/collection
const uriParts = input.replace('at://', '').split('/').filter(part => part.length > 0);
if (uriParts.length >= 3) {
// Full AT URI with rkey - show in modal
showAtUriModal(input);
return;
} else if (uriParts.length === 1) {
// Just handle in AT URI format (at://handle) - treat as regular handle
const handle = uriParts[0];
const userProfile = await resolveUserProfile(handle);
if (userProfile.success) {
displayUserDid(userProfile.data);
await loadUserCollections(handle, userProfile.data.did);
} else {
alert('ユーザーが見つかりません: ' + userProfile.error);
}
return;
} else if (uriParts.length === 2) {
// Collection level AT URI - load collection records
const [repo, collection] = uriParts;
try {
// First resolve the repo to get handle if it's a DID
let handle = repo;
if (repo.startsWith('did:')) {
// Try to resolve DID to handle - for now just use the DID
handle = repo;
}
loadCollectionRecords(handle, collection, repo);
} catch (error) {
alert('コレクションの読み込みに失敗しました: ' + error.message);
}
return;
}
}
// Handle regular handle search
const userProfile = await resolveUserProfile(input);
if (userProfile.success) {
displayUserDid(userProfile.data);
await loadUserCollections(input, userProfile.data.did);
} else {
alert('ユーザーが見つかりません: ' + userProfile.error);
}
} catch (error) {
alert('エラーが発生しました: ' + error.message);
} finally {
searchButton.disabled = false;
searchButton.innerHTML = '@';
//searchButton.innerHTML = '<i class="fab fa-bluesky"></i>';
}
}
// Resolve user profile
async function resolveUserProfile(handle) {
try {
let response = null;
// Try syu.is first
try {
response = await fetch(`${AT_PROTOCOL_CONFIG.primary.pds}/xrpc/com.atproto.repo.describeRepo?repo=${handle}`);
} catch (error) {
console.log('Failed to resolve from syu.is:', error);
}
// If syu.is fails, try bsky.social
if (!response || !response.ok) {
response = await fetch(`${AT_PROTOCOL_CONFIG.fallback.pds}/xrpc/com.atproto.repo.describeRepo?repo=${handle}`);
}
if (!response.ok) {
throw new Error('Failed to resolve handle');
}
const repoData = await response.json();
// Get profile data
const profileResponse = await fetch(`${AT_PROTOCOL_CONFIG.fallback.bsky}/xrpc/app.bsky.actor.getProfile?actor=${repoData.did}`);
const profileData = await profileResponse.json();
return {
success: true,
data: {
did: repoData.did,
handle: profileData.handle,
displayName: profileData.displayName,
avatar: profileData.avatar,
description: profileData.description,
pds: repoData.didDoc.service.find(s => s.type === 'AtprotoPersonalDataServer')?.serviceEndpoint
}
};
} catch (error) {
return {
success: false,
error: error.message
};
}
}
// Display user DID
function displayUserDid(profile) {
document.getElementById('userPdsText').textContent = profile.pds || 'Unknown';
document.getElementById('userHandleText').textContent = profile.handle;
document.getElementById('userDidText').textContent = profile.did;
document.getElementById('userDidSection').style.display = 'block';
}
// Load user collections
async function loadUserCollections(handle, did) {
const collectionsList = document.getElementById('collectionsList');
collectionsList.innerHTML = '<div class="loading">コレクションを読み込み中...</div>';
try {
// Try to get collections from describeRepo
let response = await fetch(`${AT_PROTOCOL_CONFIG.primary.pds}/xrpc/com.atproto.repo.describeRepo?repo=${handle}`);
let usedPds = AT_PROTOCOL_CONFIG.primary.pds;
// If syu.is fails, try bsky.social
if (!response.ok) {
response = await fetch(`${AT_PROTOCOL_CONFIG.fallback.pds}/xrpc/com.atproto.repo.describeRepo?repo=${handle}`);
usedPds = AT_PROTOCOL_CONFIG.fallback.pds;
}
if (!response.ok) {
throw new Error('Failed to describe repository');
}
const data = await response.json();
const collections = data.collections || [];
// Display collections as AT URI links
collectionsList.innerHTML = '';
if (collections.length === 0) {
collectionsList.innerHTML = '<div class="error">コレクションが見つかりませんでした</div>';
} else {
collections.forEach(collection => {
const atUri = `at://${did}/${collection}/`;
const collectionElement = document.createElement('a');
collectionElement.className = 'at-uri-link';
collectionElement.href = '#';
collectionElement.textContent = atUri;
collectionElement.onclick = (e) => {
e.preventDefault();
loadCollectionRecords(handle, collection, did);
// Close collections and update toggle
document.getElementById('collectionsList').style.display = 'none';
document.getElementById('collectionsToggle').textContent = '[-] Collections';
};
collectionsList.appendChild(collectionElement);
});
document.getElementById('collectionsSection').style.display = 'block';
}
} catch (error) {
collectionsList.innerHTML = '<div class="error">コレクションの読み込みに失敗しました: ' + error.message + '</div>';
document.getElementById('collectionsSection').style.display = 'block';
}
}
// Load collection records
async function loadCollectionRecords(handle, collection, did) {
const recordsList = document.getElementById('recordsList');
recordsList.innerHTML = '<div class="loading">レコードを読み込み中...</div>';
try {
// Try with syu.is first
let response = await fetch(`${AT_PROTOCOL_CONFIG.primary.pds}/xrpc/com.atproto.repo.listRecords?repo=${handle}&collection=${collection}`);
let usedPds = AT_PROTOCOL_CONFIG.primary.pds;
// If that fails, try with bsky.social
if (!response.ok) {
response = await fetch(`${AT_PROTOCOL_CONFIG.fallback.pds}/xrpc/com.atproto.repo.listRecords?repo=${handle}&collection=${collection}`);
usedPds = AT_PROTOCOL_CONFIG.fallback.pds;
}
if (!response.ok) {
throw new Error('Failed to load records');
}
const data = await response.json();
// Display records as AT URI links
recordsList.innerHTML = '';
// Add collection info for records
const collectionInfo = document.createElement('div');
collectionInfo.className = 'collection-info';
collectionInfo.innerHTML = `<strong>${collection}</strong>`;
recordsList.appendChild(collectionInfo);
data.records.forEach(record => {
const atUri = record.uri;
const recordElement = document.createElement('a');
recordElement.className = 'at-uri-link';
recordElement.href = '#';
recordElement.textContent = atUri;
recordElement.onclick = (e) => {
e.preventDefault();
showAtUriModal(atUri);
};
recordsList.appendChild(recordElement);
});
document.getElementById('recordsSection').style.display = 'block';
} catch (error) {
recordsList.innerHTML = '<div class="error">レコードの読み込みに失敗しました: ' + error.message + '</div>';
document.getElementById('recordsSection').style.display = 'block';
}
}
// Show AT URI modal
function showAtUriModal(uri) {
const modal = document.getElementById('atUriModal');
const content = document.getElementById('atUriContent');
content.innerHTML = '<div class="loading">レコードを読み込み中...</div>';
modal.style.display = 'flex';
// Load record data
loadAtUriRecord(uri, content);
}
// Load AT URI record
async function loadAtUriRecord(uri, contentElement) {
try {
const parts = uri.replace('at://', '').split('/');
const repo = parts[0];
const collection = parts[1];
const rkey = parts[2];
// Try with syu.is first
let response = await fetch(`${AT_PROTOCOL_CONFIG.primary.pds}/xrpc/com.atproto.repo.getRecord?repo=${repo}&collection=${collection}&rkey=${rkey}`);
// If that fails, try with bsky.social
if (!response.ok) {
response = await fetch(`${AT_PROTOCOL_CONFIG.fallback.pds}/xrpc/com.atproto.repo.getRecord?repo=${repo}&collection=${collection}&rkey=${rkey}`);
}
if (!response.ok) {
throw new Error('Failed to load record');
}
const data = await response.json();
contentElement.innerHTML = `
<div style="padding: 20px;">
<h3>AT URI Record</h3>
<div style="font-family: monospace; font-size: 14px; color: #666; margin-bottom: 20px; word-break: break-all;">
${uri}
</div>
<div style="font-size: 12px; color: #999; margin-bottom: 20px;">
Repo: ${repo} | Collection: ${collection} | RKey: ${rkey}
</div>
<h4>Record Data</h4>
<pre style="background: #f5f5f5; padding: 15px; border-radius: 4px; overflow: auto;">${JSON.stringify(data, null, 2)}</pre>
</div>
`;
} catch (error) {
contentElement.innerHTML = `
<div style="padding: 20px; color: red;">
<strong>Error:</strong> ${error.message}
<div style="margin-top: 10px; font-size: 12px;">
<strong>URI:</strong> ${uri}
</div>
</div>
`;
}
}
// Close AT URI modal
function closeAtUriModal(event) {
const modal = document.getElementById('atUriModal');
if (event && event.target !== modal) {
return;
}
modal.style.display = 'none';
}
// Initialize AT URI click handlers
document.addEventListener('DOMContentLoaded', function() {
// Add click handlers to existing AT URIs
document.querySelectorAll('.at-uri').forEach(element => {
element.addEventListener('click', function() {
const uri = this.getAttribute('data-at-uri');
showAtUriModal(uri);
});
});
// ESC key to close modal
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') {
closeAtUriModal();
}
});
// Enter key to search
document.getElementById('handleInput').addEventListener('keydown', function(event) {
if (event.key === 'Enter') {
searchUser();
}
});
});
// Toggle collections visibility
function toggleCollections() {
const collectionsList = document.getElementById('collectionsList');
const toggleButton = document.getElementById('collectionsToggle');
if (collectionsList.style.display === 'none') {
collectionsList.style.display = 'block';
toggleButton.textContent = '[-] Collections';
} else {
collectionsList.style.display = 'none';
toggleButton.textContent = '[+] Collections';
}
}

View File

@@ -84,7 +84,6 @@ class Theme {
setupLogoAnimations() { setupLogoAnimations() {
// Pure CSS animations are handled by the svg-animation-package.css // Pure CSS animations are handled by the svg-animation-package.css
// This method is reserved for any future JavaScript-based enhancements // This method is reserved for any future JavaScript-based enhancements
console.log('Logo animations initialized (CSS-based)');
} }
} }

View File

@@ -1,3 +1,3 @@
<!-- OAuth Comment System - Load globally for session management --> <!-- OAuth Comment System - Load globally for session management -->
<script type="module" crossorigin src="/assets/comment-atproto-BQKPMV57.js"></script> <script type="module" crossorigin src="/assets/comment-atproto-B2YEFA6R.js"></script>
<link rel="stylesheet" crossorigin href="/assets/comment-atproto-BUFiApUA.css"> <link rel="stylesheet" crossorigin href="/assets/comment-atproto-BHjafP79.css">

View File

@@ -0,0 +1,61 @@
<!-- AT Browser Integration - Temporarily disabled to fix site display -->
<!--
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script src="/assets/pds-browser.umd.js"></script>
<script>
// AT Browser integration - needs debugging
console.log('AT Browser integration temporarily disabled');
</script>
-->
<style>
/* AT Browser Modal Styles */
.at-uri-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.at-uri-modal-content {
background-color: white;
border-radius: 8px;
max-width: 800px;
max-height: 600px;
width: 90%;
height: 80%;
overflow: auto;
position: relative;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.at-uri-modal-close {
position: absolute;
top: 10px;
right: 10px;
background: none;
border: none;
font-size: 20px;
cursor: pointer;
z-index: 1001;
padding: 5px 10px;
}
/* AT URI Link Styles */
[data-at-uri] {
color: #1976d2;
cursor: pointer;
text-decoration: underline;
}
[data-at-uri]:hover {
color: #1565c0;
}
</style>

View File

@@ -12,6 +12,7 @@
<!-- Stylesheets --> <!-- Stylesheets -->
<link rel="stylesheet" href="/css/style.css"> <link rel="stylesheet" href="/css/style.css">
<link rel="stylesheet" href="/css/svg-animation-package.css"> <link rel="stylesheet" href="/css/svg-animation-package.css">
<link rel="stylesheet" href="/css/pds.css">
<link rel="stylesheet" href="/pkg/icomoon/style.css"> <link rel="stylesheet" href="/pkg/icomoon/style.css">
<link rel="stylesheet" href="/pkg/font-awesome/css/all.min.css"> <link rel="stylesheet" href="/pkg/font-awesome/css/all.min.css">
@@ -48,7 +49,18 @@
</svg> </svg>
</a> </a>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<!-- User Handle Input Form -->
<div class="pds-search-section">
<form class="pds-search-form" onsubmit="searchUser(); return false;">
<div class="form-group">
<input type="text" id="handleInput" placeholder="at://syui.ai" value="syui.ai" />
<button type="submit" id="searchButton" class="pds-btn">
@
</button>
</div>
</form>
</div>
<button class="ask-ai-btn" onclick="toggleAskAI()" id="askAiButton"> <button class="ask-ai-btn" onclick="toggleAskAI()" id="askAiButton">
<span class="ai-icon icon-ai"></span> <span class="ai-icon icon-ai"></span>
ai ai
@@ -61,7 +73,10 @@
<div class="ask-ai-panel" id="askAiPanel" style="display: none;"> <div class="ask-ai-panel" id="askAiPanel" style="display: none;">
<div class="ask-ai-content"> <div class="ask-ai-content">
<div id="authCheck" class="auth-check"> <div id="authCheck" class="auth-check">
<p>🔒 Please login with ATProto to use Ask AI feature</p> <div class="loading-content">
<div class="loading-spinner"></div>
<p>Loading...</p>
</div>
</div> </div>
<div id="chatForm" class="ask-ai-form" style="display: none;"> <div id="chatForm" class="ask-ai-form" style="display: none;">
@@ -74,6 +89,9 @@
</div> </div>
<main class="main-content"> <main class="main-content">
<!-- Pds Panel -->
{% include "pds-header.html" %}
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
@@ -84,14 +102,51 @@
<div class="footer-social"> <div class="footer-social">
<a href="https://syu.is/syui" target="_blank"><i class="fab fa-bluesky"></i></a> <a href="https://syu.is/syui" target="_blank"><i class="fab fa-bluesky"></i></a>
<a href="https://git.syui.ai/ai" target="_blank"><span class="icon-ai"></span></a> <a href="https://git.syui.ai/ai" target="_blank"><span class="icon-ai"></span></a>
<a href="https://git.syui.ai/syui" target="_blank"><span class="icon-git"></span></a> <a href="https://github.com/syui" target="_blank"><i class="fab fa-github"></i></a>
</div> </div>
<p>© {{ config.author }}</p> <p>© {{ config.author }}</p>
</footer> </footer>
<script>
// Config variables from Hugo
window.OAUTH_CONFIG = {
{% if config.oauth.pds %}
pds: "{{ config.oauth.pds }}",
{% else %}
pds: "syu.is",
{% endif %}
{% if config.oauth.admin %}
admin: "{{ config.oauth.admin }}",
{% else %}
admin: "ai.syui.ai",
{% endif %}
{% if config.oauth.collection %}
collection: "{{ config.oauth.collection }}"
{% else %}
collection: "ai.syui.log"
{% endif %}
};
</script>
<script src="/js/ask-ai.js"></script> <script src="/js/ask-ai.js"></script>
<script src="/js/pds.js"></script>
<script src="/js/theme.js"></script> <script src="/js/theme.js"></script>
<script src="/js/image-comparison.js"></script>
<!-- Mermaid support -->
<script src="https://cdn.jsdelivr.net/npm/mermaid@10.6.1/dist/mermaid.min.js"></script>
<script>
mermaid.initialize({
startOnLoad: true,
theme: 'neutral',
securityLevel: 'loose',
themeVariables: {
fontFamily: 'system-ui, -apple-system, sans-serif',
fontSize: '14px'
}
});
</script>
{% include "oauth-assets.html" %} {% include "oauth-assets.html" %}
{% include "at-browser-assets.html" %}
</body> </body>
</html> </html>

135
my-blog/templates/game.html Normal file
View File

@@ -0,0 +1,135 @@
{% extends "base.html" %}
{% block title %}Game - {{ config.title }}{% endblock %}
{% block content %}
<div id="gameContainer" class="game-container">
<div id="gameAuth" class="game-auth-section">
<h1>Login to Play</h1>
<p>Please authenticate with your AT Protocol account to access the game.</p>
<div id="authRoot"></div>
</div>
<div id="gameFrame" class="game-frame-container" style="display: none;">
<iframe
id="pixelStreamingFrame"
src="https://verse.syui.ai/simple-noui.html"
frameborder="0"
allowfullscreen
allow="microphone; camera; fullscreen; autoplay"
class="pixel-streaming-iframe"
></iframe>
</div>
</div>
<style>
/* Game specific styles */
.game-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100vh;
background: #000;
overflow: hidden;
}
.game-auth-section {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%);
color: white;
}
.game-auth-section h1 {
font-size: 2.5em;
margin-bottom: 20px;
color: #fff;
}
.game-auth-section p {
font-size: 1.2em;
margin-bottom: 30px;
color: #ccc;
}
.game-frame-container {
width: 100%;
height: 100vh;
position: relative;
}
.pixel-streaming-iframe {
width: 100%;
height: 100%;
border: none;
}
/* Override auth button for game page */
.game-auth-section .auth-section {
background: transparent;
box-shadow: none;
}
.game-auth-section .auth-button {
font-size: 1.2em;
padding: 12px 30px;
}
/* Hide header and footer on game page */
body:has(.game-container) header,
body:has(.game-container) footer,
body:has(.game-container) nav {
display: none !important;
}
/* Remove any body padding/margin for full screen game */
body:has(.game-container) {
margin: 0;
padding: 0;
overflow: hidden;
}
</style>
<script>
// Wait for OAuth component to be loaded
document.addEventListener('DOMContentLoaded', function() {
// Check if user is already authenticated
const checkAuthStatus = () => {
// Check if OAuth components are available and user is authenticated
if (window.currentUser && window.currentAgent) {
showGame();
return true;
}
return false;
};
// Show game iframe
const showGame = () => {
document.getElementById('gameAuth').style.display = 'none';
document.getElementById('gameFrame').style.display = 'block';
};
// Listen for OAuth success
window.addEventListener('oauth-success', function(event) {
console.log('OAuth success:', event.detail);
showGame();
});
// Check auth status on load
if (!checkAuthStatus()) {
// Check periodically if OAuth components are loaded
const authCheckInterval = setInterval(() => {
if (checkAuthStatus()) {
clearInterval(authCheckInterval);
}
}, 500);
}
});
</script>
<!-- Include OAuth assets -->
{% include "oauth-assets.html" %}
{% endblock %}

View File

@@ -0,0 +1,48 @@
<div class="pds-container">
<div class="pds-header">
</div>
<!-- Current User DID -->
<div id="userDidSection" class="user-did-section" style="display: none;">
<div class="pds-display">
<strong>PDS:</strong> <span id="userPdsText"></span>
</div>
<div class="handle-display">
<strong>Handle:</strong> <span id="userHandleText"></span>
</div>
<div class="did-display">
<span id="userDidText"></span>
</div>
</div>
<!-- Collection List -->
<div id="collectionsSection" class="collections-section" style="display: none;">
<div class="collections-header">
<button id="collectionsToggle" class="collections-toggle" onclick="toggleCollections()">[+] Collections</button>
</div>
<div id="collectionsList" class="collections-list" style="display: none;">
<!-- Collections will be populated here -->
</div>
</div>
<!-- AT URI Records -->
<div id="recordsSection" class="records-section" style="display: none;">
<div id="recordsList" class="records-list">
<!-- Records will be populated here -->
</div>
</div>
</div>
<!-- AT URI Modal -->
<div id="atUriModal" class="at-uri-modal-overlay" style="display: none;" onclick="closeAtUriModal(event)">
<div class="at-uri-modal-content">
<button class="at-uri-modal-close" onclick="closeAtUriModal()">&times;</button>
<div id="atUriContent"></div>
</div>
</div>

View File

@@ -0,0 +1,6 @@
{% extends "base.html" %}
{% block title %}at-uri browser - {{ config.title }}{% endblock %}
{% block content %}
{% endblock %}

View File

@@ -13,6 +13,7 @@
<span class="article-lang">{{ post.language }}</span> <span class="article-lang">{{ post.language }}</span>
{% endif %} {% endif %}
</div> </div>
{% if not post.extra.type or post.extra.type != "ai" %}
<div class="article-actions"> <div class="article-actions">
{% if post.markdown_url %} {% if post.markdown_url %}
<a href="{{ post.markdown_url }}" class="action-btn markdown-btn" title="View Markdown"> <a href="{{ post.markdown_url }}" class="action-btn markdown-btn" title="View Markdown">
@@ -25,29 +26,35 @@
</a> </a>
{% endif %} {% endif %}
</div> </div>
{% endif %}
</header> </header>
<div class="article-body"> {% if not post.extra.type or post.extra.type != "ai" %}
{{ post.content | safe }}
</div>
<div id="comment-atproto"></div>
</article>
<aside class="article-sidebar">
<nav class="toc"> <nav class="toc">
<h3>Contents</h3> <h3>Contents</h3>
<div id="toc-content"> <div id="toc-content">
<!-- TOC will be generated by JavaScript --> <!-- TOC will be generated by JavaScript -->
</div> </div>
</nav> </nav>
</aside>
<div class="article-body">
{{ post.content | safe }}
</div>
{% endif %}
<div id="comment-atproto"></div>
</article>
</div> </div>
<script> <script>
// Generate table of contents // Generate table of contents
function generateTableOfContents() { function generateTableOfContents() {
const tocContainer = document.getElementById('toc-content'); const tocContainer = document.getElementById('toc-content');
if (!tocContainer) {
return;
}
const headings = document.querySelectorAll('.article-body h1, .article-body h2, .article-body h3, .article-body h4, .article-body h5, .article-body h6'); const headings = document.querySelectorAll('.article-body h1, .article-body h2, .article-body h3, .article-body h4, .article-body h5, .article-body h6');
if (headings.length === 0) { if (headings.length === 0) {

View File

@@ -16,4 +16,4 @@ VITE_AI_SYSTEM_PROMPT="あなたは6歳の女の子アイです。明るく元
# Production settings - Disable development features # Production settings - Disable development features
VITE_ENABLE_TEST_UI=false VITE_ENABLE_TEST_UI=false
VITE_ENABLE_DEBUG=false VITE_ENABLE_DEBUG=true

View File

@@ -1,116 +0,0 @@
# Ask-AI Integration Implementation
## 概要
oauth_new アプリに ask-AI 機能を統合しました。この機能により、ユーザーはAIと対話し、その結果を atproto に記録できます。
## 実装されたファイル
### 1. `/src/hooks/useAskAI.js`
- ask-AI サーバーとの通信機能
- atproto への putRecord 機能
- チャット履歴の管理
- イベント送信blog との通信用)
### 2. `/src/components/AskAI.jsx`
- チャット UI コンポーネント
- 質問入力・回答表示
- 認証チェック
- IME 対応
### 3. `/src/App.jsx` の更新
- AskAI コンポーネントの統合
- Ask AI ボタンの追加
- イベントリスナーの設定
- blog との通信機能
## JSON 構造の記録
`./json/` ディレクトリに各 collection の構造を記録しました:
- `ai.syui.ai_user.json` - ユーザーリスト
- `ai.syui.ai_chat.json` - チャット記録(空)
- `syui.syui.ai_chat.json` - チャット記録(実データ)
- `ai.syui.ai_chat_lang.json` - 翻訳記録
- `ai.syui.ai_chat_comment.json` - コメント記録
## 実際の ai.syui.log.chat 構造
確認された実際の構造:
```json
{
"$type": "ai.syui.log.chat",
"post": {
"url": "https://syui.ai/",
"date": "2025-06-18T02:16:04.609Z",
"slug": "",
"tags": [],
"title": "syui.ai",
"language": "ja"
},
"text": "質問またはAI回答テキスト",
"type": "question|answer",
"author": {
"did": "did:plc:...",
"handle": "handle名",
"displayName": "表示名",
"avatar": "アバターURL"
},
"createdAt": "2025-06-18T02:16:04.609Z"
}
```
## イベント通信
blogask-ai.jsと OAuth アプリ間の通信:
### 送信イベント
- `postAIQuestion` - blog から OAuth アプリへ質問送信
- `aiProfileLoaded` - OAuth アプリから blog へ AI プロフィール送信
- `aiResponseReceived` - OAuth アプリから blog へ AI 回答送信
### 受信イベント
- OAuth アプリが `postAIQuestion` を受信して処理
- blog が `aiResponseReceived` を受信して表示
## 環境変数
```env
VITE_ASK_AI_URL=http://localhost:3000/ask # ask-AI サーバーURLデフォルト
VITE_ADMIN_HANDLE=ai.syui.ai
VITE_ATPROTO_PDS=syu.is
VITE_OAUTH_COLLECTION=ai.syui.log
```
## 機能
### 実装済み
- ✅ ask-AI サーバーとの通信
- ✅ atproto への question/answer record 保存
- ✅ チャット履歴の表示・管理
- ✅ blog との双方向イベント通信
- ✅ 認証機能(ログイン必須)
- ✅ エラーハンドリング・ローディング状態
- ✅ 実際の JSON 構造に合わせた実装
### 今後のテスト項目
- ask-AI サーバーの準備・起動
- 実際の質問送信テスト
- atproto への putRecord 動作確認
- blog からの連携テスト
## 使用方法
1. 開発サーバー起動: `npm run dev`
2. OAuth ログイン実行
3. "Ask AI" ボタンをクリック
4. チャット画面で質問入力
5. AI の回答が表示され、atproto に記録される
## 注意事項
- ask-AI サーバーVITE_ASK_AI_URLが必要
- 認証されたユーザーのみ質問可能
- ai.syui.log.chat への書き込み権限が必要
- Production 環境では logger が無効化される

View File

@@ -1,174 +0,0 @@
# Avatar Fetching System
This document describes the avatar fetching system implemented for the oauth_new application.
## Overview
The avatar system provides intelligent avatar fetching with fallback mechanisms, caching, and error handling. It follows the design specified in the project instructions:
1. **Primary Source**: Try to use avatar from record JSON first
2. **Fallback**: If avatar is broken/missing, fetch fresh data from ATProto
3. **Fresh Data Flow**: handle → PDS → DID → profile → avatar URI
4. **Caching**: Avoid excessive API calls with intelligent caching
## Files Structure
```
src/
├── utils/
│ └── avatar.js # Core avatar fetching logic
├── components/
│ ├── Avatar.jsx # React avatar component
│ └── AvatarTest.jsx # Test component
└── App.css # Avatar styling
```
## Core Functions
### `getAvatar(options)`
Main function to fetch avatar with intelligent fallback.
```javascript
const avatar = await getAvatar({
record: recordObject, // Optional: record containing avatar data
handle: 'user.handle', // Required if no record
did: 'did:plc:xxx', // Optional: user DID
forceFresh: false // Optional: force fresh fetch
})
```
### `batchFetchAvatars(users)`
Fetch avatars for multiple users in parallel with concurrency control.
```javascript
const avatarMap = await batchFetchAvatars([
{ handle: 'user1.handle', did: 'did:plc:xxx1' },
{ handle: 'user2.handle', did: 'did:plc:xxx2' }
])
```
### `prefetchAvatar(handle)`
Prefetch and cache avatar for a specific handle.
```javascript
await prefetchAvatar('user.handle')
```
## React Components
### `<Avatar>`
Basic avatar component with loading states and fallbacks.
```jsx
<Avatar
record={record}
handle="user.handle"
did="did:plc:xxx"
size={40}
showFallback={true}
onLoad={() => console.log('loaded')}
onError={(err) => console.log('error', err)}
/>
```
### `<AvatarWithCard>`
Avatar with hover card showing user information.
```jsx
<AvatarWithCard
record={record}
displayName="User Name"
apiConfig={apiConfig}
size={60}
/>
```
### `<AvatarList>`
Display multiple avatars with overlap effect.
```jsx
<AvatarList
users={userArray}
maxDisplay={5}
size={30}
/>
```
## Data Flow
1. **Record Check**: Extract avatar from record.value.author.avatar
2. **URL Validation**: Verify avatar URL is accessible (HEAD request)
3. **Fresh Fetch**: If broken, fetch fresh data:
- Get PDS from handle using `getPdsFromHandle()`
- Get API config using `getApiConfig()`
- Get DID from PDS using `atproto.getDid()`
- Get profile from bsky API using `atproto.getProfile()`
- Extract avatar from profile
4. **Cache**: Store result in cache with 30-minute TTL
5. **Fallback**: Show initial-based fallback if no avatar found
## Caching Strategy
- **Cache Key**: `avatar:{handle}`
- **Duration**: 30 minutes (configurable)
- **Cache Provider**: Uses existing `dataCache` utility
- **Invalidation**: Manual cache clearing functions available
## Error Handling
- **Network Errors**: Gracefully handled with fallback UI
- **Broken URLs**: Automatically detected and re-fetched fresh
- **Missing Handles**: Throws descriptive error messages
- **API Failures**: Logged but don't break UI
## Integration
The avatar system is integrated into the existing RecordList component:
```jsx
// Old approach
{record.value.author?.avatar && (
<img src={record.value.author.avatar} alt="avatar" className="avatar" />
)}
// New approach
<Avatar
record={record}
handle={record.value.author?.handle}
did={record.value.author?.did}
size={40}
showFallback={true}
/>
```
## Testing
The system includes a comprehensive test component (`AvatarTest.jsx`) that can be accessed through the Test UI in the app. It demonstrates:
1. Avatar from record data
2. Avatar from handle only
3. Broken avatar URL handling
4. Batch fetching
5. Prefetch functionality
6. Various avatar components
To test:
1. Open the app
2. Click "Test" button in header
3. Switch to "Avatar System" tab
4. Use the test controls to verify functionality
## Performance Considerations
- **Concurrent Fetching**: Batch operations use concurrency limits (5 parallel requests)
- **Caching**: Reduces API calls by caching results
- **Lazy Loading**: Avatar images use lazy loading
- **Error Recovery**: Broken avatars are automatically retried with fresh data
## Future Enhancements
1. **Persistent Cache**: Consider localStorage for cross-session caching
2. **Image Optimization**: Add WebP support and size optimization
3. **Preloading**: Implement smarter preloading strategies
4. **CDN Integration**: Add CDN support for avatar delivery
5. **Placeholder Variations**: More diverse fallback avatar styles

View File

@@ -1,420 +0,0 @@
# Avatar System Usage Examples
This document provides practical examples of how to use the avatar fetching system in your components.
## Basic Usage
### Simple Avatar Display
```jsx
import Avatar from './components/Avatar.jsx'
function UserProfile({ user }) {
return (
<div className="user-profile">
<Avatar
handle={user.handle}
did={user.did}
size={80}
alt={`${user.displayName}'s avatar`}
/>
<h3>{user.displayName}</h3>
</div>
)
}
```
### Avatar from Record Data
```jsx
function CommentItem({ record }) {
return (
<div className="comment">
<Avatar
record={record}
size={40}
showFallback={true}
/>
<div className="comment-content">
<strong>{record.value.author.displayName}</strong>
<p>{record.value.text}</p>
</div>
</div>
)
}
```
### Avatar with Hover Card
```jsx
import { AvatarWithCard } from './components/Avatar.jsx'
function UserList({ users, apiConfig }) {
return (
<div className="user-list">
{users.map(user => (
<AvatarWithCard
key={user.handle}
handle={user.handle}
did={user.did}
displayName={user.displayName}
apiConfig={apiConfig}
size={50}
/>
))}
</div>
)
}
```
## Advanced Usage
### Programmatic Avatar Fetching
```jsx
import { useEffect, useState } from 'react'
import { getAvatar, batchFetchAvatars } from './utils/avatar.js'
function useUserAvatars(users) {
const [avatars, setAvatars] = useState(new Map())
const [loading, setLoading] = useState(false)
useEffect(() => {
async function fetchAvatars() {
setLoading(true)
try {
const avatarMap = await batchFetchAvatars(users)
setAvatars(avatarMap)
} catch (error) {
console.error('Failed to fetch avatars:', error)
} finally {
setLoading(false)
}
}
if (users.length > 0) {
fetchAvatars()
}
}, [users])
return { avatars, loading }
}
// Usage
function TeamDisplay({ team }) {
const { avatars, loading } = useUserAvatars(team.members)
if (loading) return <div>Loading team...</div>
return (
<div className="team">
{team.members.map(member => (
<img
key={member.handle}
src={avatars.get(member.handle) || '/default-avatar.png'}
alt={member.displayName}
/>
))}
</div>
)
}
```
### Force Refresh Avatar
```jsx
import { useState } from 'react'
import Avatar from './components/Avatar.jsx'
import { getAvatar, clearAvatarCache } from './utils/avatar.js'
function RefreshableAvatar({ handle, did }) {
const [key, setKey] = useState(0)
const handleRefresh = async () => {
// Clear cache for this user
clearAvatarCache(handle)
// Force re-render of Avatar component
setKey(prev => prev + 1)
// Optionally, prefetch fresh avatar
try {
await getAvatar({ handle, did, forceFresh: true })
} catch (error) {
console.error('Failed to refresh avatar:', error)
}
}
return (
<div className="refreshable-avatar">
<Avatar
key={key}
handle={handle}
did={did}
size={60}
/>
<button onClick={handleRefresh}>
Refresh Avatar
</button>
</div>
)
}
```
### Avatar List with Overflow
```jsx
import { AvatarList } from './components/Avatar.jsx'
function ParticipantsList({ participants, maxVisible = 5 }) {
return (
<div className="participants">
<h4>Participants ({participants.length})</h4>
<AvatarList
users={participants}
maxDisplay={maxVisible}
size={32}
/>
{participants.length > maxVisible && (
<span className="overflow-text">
and {participants.length - maxVisible} more...
</span>
)}
</div>
)
}
```
## Error Handling
### Custom Error Handling
```jsx
import { useState } from 'react'
import Avatar from './components/Avatar.jsx'
function RobustAvatar({ handle, did, fallbackSrc }) {
const [hasError, setHasError] = useState(false)
const handleError = (error) => {
console.warn(`Avatar failed for ${handle}:`, error)
setHasError(true)
}
if (hasError && fallbackSrc) {
return (
<img
src={fallbackSrc}
alt="Fallback avatar"
className="avatar"
onError={() => setHasError(false)} // Reset on fallback error
/>
)
}
return (
<Avatar
handle={handle}
did={did}
onError={handleError}
showFallback={!hasError}
/>
)
}
```
### Loading States
```jsx
import { useState, useEffect } from 'react'
import { getAvatar } from './utils/avatar.js'
function AvatarWithCustomLoading({ handle, did }) {
const [avatar, setAvatar] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
async function loadAvatar() {
try {
setLoading(true)
setError(null)
const avatarUrl = await getAvatar({ handle, did })
setAvatar(avatarUrl)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
loadAvatar()
}, [handle, did])
if (loading) {
return <div className="avatar-loading-spinner">Loading...</div>
}
if (error) {
return <div className="avatar-error">Failed to load avatar</div>
}
if (!avatar) {
return <div className="avatar-placeholder">No avatar</div>
}
return <img src={avatar} alt="Avatar" className="avatar" />
}
```
## Optimization Patterns
### Preloading Strategy
```jsx
import { useEffect } from 'react'
import { prefetchAvatar } from './utils/avatar.js'
function UserCard({ user, isVisible }) {
// Preload avatar when component becomes visible
useEffect(() => {
if (isVisible && user.handle) {
prefetchAvatar(user.handle)
}
}, [isVisible, user.handle])
return (
<div className="user-card">
{isVisible && (
<Avatar handle={user.handle} did={user.did} />
)}
<h4>{user.displayName}</h4>
</div>
)
}
```
### Lazy Loading with Intersection Observer
```jsx
import { useState, useEffect, useRef } from 'react'
import Avatar from './components/Avatar.jsx'
function LazyAvatar({ handle, did, ...props }) {
const [isVisible, setIsVisible] = useState(false)
const ref = useRef()
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true)
observer.disconnect()
}
},
{ threshold: 0.1 }
)
if (ref.current) {
observer.observe(ref.current)
}
return () => observer.disconnect()
}, [])
return (
<div ref={ref}>
{isVisible ? (
<Avatar handle={handle} did={did} {...props} />
) : (
<div className="avatar-placeholder" style={{
width: props.size || 40,
height: props.size || 40
}} />
)}
</div>
)
}
```
## Cache Management
### Cache Statistics Display
```jsx
import { useEffect, useState } from 'react'
import { getAvatarCacheStats, cleanupExpiredAvatars } from './utils/avatarCache.js'
function CacheStatsPanel() {
const [stats, setStats] = useState(null)
useEffect(() => {
const updateStats = () => {
setStats(getAvatarCacheStats())
}
updateStats()
const interval = setInterval(updateStats, 5000) // Update every 5 seconds
return () => clearInterval(interval)
}, [])
const handleCleanup = async () => {
const cleaned = cleanupExpiredAvatars()
alert(`Cleaned ${cleaned} expired cache entries`)
setStats(getAvatarCacheStats())
}
if (!stats) return null
return (
<div className="cache-stats">
<h4>Avatar Cache Stats</h4>
<p>Cached avatars: {stats.totalCached}</p>
<p>Cache hit rate: {stats.hitRate}%</p>
<p>Cache hits: {stats.cacheHits}</p>
<p>Cache misses: {stats.cacheMisses}</p>
<button onClick={handleCleanup}>
Clean Expired Cache
</button>
</div>
)
}
```
## Testing Helpers
### Mock Avatar for Testing
```jsx
// For testing environments
const MockAvatar = ({ handle, size = 40, showFallback = true }) => {
if (!showFallback) return null
const initial = (handle || 'U')[0].toUpperCase()
return (
<div
className="avatar-mock"
style={{
width: size,
height: size,
borderRadius: '50%',
backgroundColor: '#e1e1e1',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: size * 0.4,
color: '#666'
}}
>
{initial}
</div>
)
}
// Use in tests
export default process.env.NODE_ENV === 'test' ? MockAvatar : Avatar
```
These examples demonstrate the flexibility and power of the avatar system while maintaining good performance and user experience practices.

View File

@@ -1,57 +0,0 @@
name: Deploy to Cloudflare Pages
on:
push:
branches:
- main
workflow_dispatch:
env:
OAUTH_DIR: oauth_new
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: ${{ env.OAUTH_DIR }}/package-lock.json
- name: Install dependencies
run: |
cd ${{ env.OAUTH_DIR }}
npm ci
- name: Build OAuth app
run: |
cd ${{ env.OAUTH_DIR }}
NODE_ENV=production npm run build
env:
VITE_ADMIN: ${{ secrets.VITE_ADMIN }}
VITE_PDS: ${{ secrets.VITE_PDS }}
VITE_HANDLE_LIST: ${{ secrets.VITE_HANDLE_LIST }}
VITE_COLLECTION: ${{ secrets.VITE_COLLECTION }}
VITE_OAUTH_CLIENT_ID: ${{ secrets.VITE_OAUTH_CLIENT_ID }}
VITE_OAUTH_REDIRECT_URI: ${{ secrets.VITE_OAUTH_REDIRECT_URI }}
VITE_ENABLE_TEST_UI: 'false'
VITE_ENABLE_DEBUG: 'false'
- name: Deploy to Cloudflare Pages
uses: cloudflare/pages-action@v1
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
projectName: ${{ secrets.CLOUDFLARE_PROJECT_NAME }}
directory: ${{ env.OAUTH_DIR }}/dist
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
deploymentName: Production

View File

@@ -1,104 +0,0 @@
name: Deploy to Cloudflare Pages
on:
push:
branches:
- main
workflow_dispatch:
env:
OAUTH_DIR: oauth_new
KEEP_DEPLOYMENTS: 5 # 保持するデプロイメント数
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: ${{ env.OAUTH_DIR }}/package-lock.json
- name: Install dependencies
run: |
cd ${{ env.OAUTH_DIR }}
npm ci
- name: Build OAuth app
run: |
cd ${{ env.OAUTH_DIR }}
NODE_ENV=production npm run build
env:
VITE_ADMIN: ${{ secrets.VITE_ADMIN }}
VITE_PDS: ${{ secrets.VITE_PDS }}
VITE_HANDLE_LIST: ${{ secrets.VITE_HANDLE_LIST }}
VITE_COLLECTION: ${{ secrets.VITE_COLLECTION }}
VITE_OAUTH_CLIENT_ID: ${{ secrets.VITE_OAUTH_CLIENT_ID }}
VITE_OAUTH_REDIRECT_URI: ${{ secrets.VITE_OAUTH_REDIRECT_URI }}
VITE_ENABLE_TEST_UI: 'false'
VITE_ENABLE_DEBUG: 'false'
- name: Deploy to Cloudflare Pages
uses: cloudflare/pages-action@v1
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
projectName: ${{ secrets.CLOUDFLARE_PROJECT_NAME }}
directory: ${{ env.OAUTH_DIR }}/dist
gitHubToken: ${{ secrets.GITHUB_TOKEN }}
deploymentName: Production
cleanup:
needs: deploy
runs-on: ubuntu-latest
if: success()
steps:
- name: Wait for deployment to complete
run: sleep 30
- name: Cleanup old deployments
run: |
# Get all deployments
DEPLOYMENTS=$(curl -s -X GET \
"https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${{ secrets.CLOUDFLARE_PROJECT_NAME }}/deployments" \
-H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \
-H "Content-Type: application/json")
# Extract deployment IDs (skip the latest N deployments)
DEPLOYMENT_IDS=$(echo "$DEPLOYMENTS" | jq -r ".result | sort_by(.created_on) | reverse | .[${{ env.KEEP_DEPLOYMENTS }}:] | .[].id // empty")
if [ -z "$DEPLOYMENT_IDS" ]; then
echo "No old deployments to delete"
exit 0
fi
# Delete old deployments
for ID in $DEPLOYMENT_IDS; do
echo "Deleting deployment: $ID"
RESPONSE=$(curl -s -X DELETE \
"https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${{ secrets.CLOUDFLARE_PROJECT_NAME }}/deployments/$ID" \
-H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \
-H "Content-Type: application/json")
SUCCESS=$(echo "$RESPONSE" | jq -r '.success')
if [ "$SUCCESS" = "true" ]; then
echo "Successfully deleted deployment: $ID"
else
echo "Failed to delete deployment: $ID"
echo "$RESPONSE" | jq .
fi
sleep 1 # Rate limiting
done
echo "Cleanup completed!"

View File

@@ -1,178 +0,0 @@
# 本番環境デプロイメント手順
## 本番環境用の調整
### 1. テスト機能の削除・無効化
本番環境では以下の調整が必要です:
#### A. TestUI コンポーネントの削除
```jsx
// src/App.jsx から以下を削除/コメントアウト
import TestUI from './components/TestUI.jsx'
const [showTestUI, setShowTestUI] = useState(false)
// ボトムセクションからTestUIを削除
{showTestUI && (
<TestUI />
)}
<button
onClick={() => setShowTestUI(!showTestUI)}
className={`btn ${showTestUI ? 'btn-danger' : 'btn-outline'} btn-sm`}
>
{showTestUI ? 'close test' : 'test'}
</button>
```
#### B. ログ出力の完全無効化
現在は `logger.js` で開発環境のみログが有効になっていますが、完全に確実にするため:
```bash
# 本番ビルド前に全てのconsole.logを確認
grep -r "console\." src/ --exclude-dir=node_modules
```
### 2. 環境変数の設定
#### 本番用 .env.production
```bash
VITE_ATPROTO_PDS=syu.is
VITE_ADMIN_HANDLE=ai.syui.ai
VITE_AI_HANDLE=ai.syui.ai
VITE_OAUTH_COLLECTION=ai.syui.log
```
### 3. ビルドコマンド
```bash
# 本番用ビルド
npm run build
# 生成されるファイル確認
ls -la dist/
```
### 4. デプロイ用ファイル構成
```
dist/
├── index.html # 最小化HTML
├── assets/
│ ├── comment-atproto-[hash].js # メインJSバンドル
│ └── comment-atproto-[hash].css # CSS
```
### 5. ailog サイトへの統合
#### A. アセットファイルのコピー
```bash
# distファイルをailogサイトの適切な場所にコピー
cp dist/assets/* /path/to/ailog/static/assets/
cp dist/index.html /path/to/ailog/templates/oauth-assets.html
```
#### B. ailog テンプレートでの読み込み
```html
<!-- ailog のテンプレートに追加 -->
{{ if .Site.Params.oauth_comments }}
{{ partial "oauth-assets.html" . }}
{{ end }}
```
### 6. 本番環境チェックリスト
#### ✅ セキュリティ
- [ ] OAuth認証のリダイレクトURL確認
- [ ] 環境変数の機密情報確認
- [ ] HTTPS通信確認
#### ✅ パフォーマンス
- [ ] バンドルサイズ確認現在1.2MB
- [ ] ファイル圧縮確認
- [ ] キャッシュ設定確認
#### ✅ 機能
- [ ] 本番PDS接続確認
- [ ] OAuth認証フロー確認
- [ ] コメント投稿・表示確認
- [ ] アバター表示確認
#### ✅ UI/UX
- [ ] モバイル表示確認
- [ ] アクセシビリティ確認
- [ ] エラーハンドリング確認
### 7. 段階的デプロイ戦略
#### Phase 1: テスト環境
```bash
# テスト用のサブドメインでデプロイ
# test.syui.ai など
```
#### Phase 2: 本番環境
```bash
# 問題なければ本番環境にデプロイ
# ailog本体に統合
```
### 8. トラブルシューティング
#### よくある問題
1. **OAuth認証エラー**: リダイレクトURL設定確認
2. **PDS接続エラー**: ネットワーク・DNS設定確認
3. **アバター表示エラー**: CORS設定確認
4. **CSS競合**: oauth-プレフィックス確認
#### ログ確認方法
```bash
# 本番環境でエラーが発生した場合
# ブラウザのDevToolsでエラー確認
# logger.jsは本番では無効化されている
```
### 9. 本番用設定ファイル
```bash
# ~/.config/syui/ai/log/config.json
{
"oauth": {
"environment": "production",
"debug": false,
"test_mode": false
}
}
```
### 10. 推奨デプロイ手順
```bash
# 1. テスト機能削除
git checkout -b production-ready
# App.jsx からTestUI関連を削除
# 2. 本番ビルド
npm run build
# 3. ファイル確認
ls -la dist/
# 4. ailogサイトに統合
cp dist/assets/* ../my-blog/static/assets/
cp dist/index.html ../my-blog/templates/oauth-assets.html
# 5. ailogサイトでテスト
cd ../my-blog
hugo server
# 6. 問題なければcommit
git add .
git commit -m "Production build: Remove test UI, optimize for deployment"
```
## 注意事項
- TestUIは開発・デモ用のため本番では削除必須
- loggerは自動で本番では無効化される
- OAuth設定は本番PDS用に調整必要
- バンドルサイズが大きいため今後最適化検討

View File

@@ -1,334 +0,0 @@
# 開発ガイド
## 設計思想
このプロジェクトは以下の原則に基づいて設計されています:
### 1. 環境変数による設定の外部化
- ハードコードを避け、設定は全て環境変数で管理
- `src/config/env.js` で一元管理
### 2. PDSPersonal Data Serverの自動判定
- `VITE_HANDLE_LIST``VITE_PDS` による自動判定
- syu.is系とbsky.social系の自動振り分け
### 3. コンポーネントの責任分離
- Hooks: ビジネスロジック
- Components: UI表示のみ
- Services: 外部API連携
- Utils: 純粋関数
## アーキテクチャ詳細
### データフロー
```
User Input
Hooks (useAuth, useAdminData, usePageContext)
Services (OAuthService)
API (atproto.js)
ATProto Network
Components (UI Display)
```
### 状態管理
React Hooksによる状態管理
- `useAuth`: OAuth認証状態
- `useAdminData`: 管理者データ(プロフィール、レコード)
- `usePageContext`: ページ判定(トップ/個別)
### OAuth認証フロー
```
1. ユーザーがハンドル入力
2. PDS判定 (syu.is vs bsky.social)
3. 適切なOAuthClientを選択
4. 標準OAuth画面にリダイレクト
5. 認証完了後コールバック処理
6. セッション復元・保存
```
## 重要な実装詳細
### セッション管理
`@atproto/oauth-client-browser`が自動的に以下を処理:
- IndexedDBへのセッション保存
- トークンの自動更新
- DPoPDemonstration of Proof of Possession
**注意**: 手動でのセッション管理は複雑なため、公式ライブラリを使用すること。
### PDS判定アルゴリズム
```javascript
// src/utils/pds.js
function isSyuIsHandle(handle) {
return env.handleList.includes(handle) || handle.endsWith(`.${env.pds}`)
}
```
1. `VITE_HANDLE_LIST` に含まれるハンドル → syu.is
2. `.syu.is` で終わるハンドル → syu.is
3. その他 → bsky.social
### レコードフィルタリング
```javascript
// src/components/RecordTabs.jsx
const filterRecords = (records) => {
if (pageContext.isTopPage) {
return records.slice(0, 3) // 最新3件
} else {
// URL のrkey と record.value.post.url のrkey を照合
return records.filter(record => {
const recordRkey = new URL(record.value.post.url).pathname.split('/').pop()?.replace(/\.html$/, '')
return recordRkey === pageContext.rkey
})
}
}
```
## 開発時の注意点
### 1. 環境変数の命名
- `VITE_` プレフィックス必須Viteの制約
- JSON形式の環境変数は文字列として定義
```bash
# ❌ 間違い
VITE_HANDLE_LIST=["ai.syui.ai"]
# ✅ 正しい
VITE_HANDLE_LIST=["ai.syui.ai", "syui.syui.ai"]
```
### 2. API エラーハンドリング
```javascript
// src/api/atproto.js
async function request(url) {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
return await response.json()
}
```
すべてのAPI呼び出しでエラーハンドリングを実装。
### 3. コンポーネント設計
```javascript
// ❌ Bad: ビジネスロジックがコンポーネント内
function MyComponent() {
const [data, setData] = useState([])
useEffect(() => {
fetch('/api/data').then(setData)
}, [])
return <div>{data.map(...)}</div>
}
// ✅ Good: Hooksでロジック分離
function MyComponent() {
const { data, loading, error } = useMyData()
if (loading) return <Loading />
if (error) return <Error />
return <div>{data.map(...)}</div>
}
```
## デバッグ手法
### 1. OAuth デバッグ
```javascript
// ブラウザの開発者ツールで確認
localStorage.clear() // セッションクリア
sessionStorage.clear() // 一時データクリア
// IndexedDB確認Application タブ)
// ATProtoの認証データが保存される
```
### 2. PDS判定デバッグ
```javascript
// src/utils/pds.js にログ追加
console.log('Handle:', handle)
console.log('Is syu.is:', isSyuIsHandle(handle))
console.log('API Config:', getApiConfig(pds))
```
### 3. レコードフィルタリングデバッグ
```javascript
// src/components/RecordTabs.jsx
console.log('Page Context:', pageContext)
console.log('All Records:', records.length)
console.log('Filtered Records:', filteredRecords.length)
```
## パフォーマンス最適化
### 1. 並列データ取得
```javascript
// src/hooks/useAdminData.js
const [records, lang, comment] = await Promise.all([
collections.getBase(apiConfig.pds, did, env.collection),
collections.getLang(apiConfig.pds, did, env.collection),
collections.getComment(apiConfig.pds, did, env.collection)
])
```
### 2. 不要な再レンダリング防止
```javascript
// useMemo でフィルタリング結果をキャッシュ
const filteredRecords = useMemo(() =>
filterRecords(records),
[records, pageContext]
)
```
## テスト戦略
### 1. 単体テスト推奨対象
- `src/utils/pds.js` - PDS判定ロジック
- `src/config/env.js` - 環境変数パース
- フィルタリング関数
### 2. 統合テスト推奨対象
- OAuth認証フロー
- API呼び出し
- レコード表示
## デプロイメント
### 1. 必要ファイル
```
public/
└── client-metadata.json # OAuth設定ファイル
dist/ # ビルド出力
├── index.html
└── assets/
├── comment-atproto-[hash].js
└── comment-atproto-[hash].css
```
### 2. デプロイ手順
```bash
# 1. 環境変数設定
cp .env.example .env
# 2. 本番用設定を記入
# 3. ビルド
npm run build
# 4. dist/ フォルダをデプロイ
```
### 3. 本番環境チェックリスト
- [ ] `.env` ファイルの本番設定
- [ ] `client-metadata.json` の設置
- [ ] HTTPS 必須OAuth要件
- [ ] CSPContent Security Policy設定
## よくある問題と解決法
### 1. "OAuth initialization failed"
**原因**: client-metadata.json が見つからない、または形式が正しくない
**解決法**:
```bash
# public/client-metadata.json の存在確認
ls -la public/client-metadata.json
# 形式確認JSON validation
jq . public/client-metadata.json
```
### 2. "Failed to load admin data"
**原因**: 管理者アカウントのDID解決に失敗
**解決法**:
```bash
# 手動でDID解決確認
curl "https://syu.is/xrpc/com.atproto.repo.describeRepo?repo=ai.syui.ai"
```
### 3. レコードが表示されない
**原因**: コレクション名の不一致、権限不足
**解決法**:
```bash
# コレクション確認
curl "https://syu.is/xrpc/com.atproto.repo.listRecords?repo=did:plc:xxx&collection=ai.syui.log.chat.lang"
```
## 機能拡張ガイド
### 1. 新しいコレクション追加
```javascript
// src/api/atproto.js に追加
export const collections = {
// 既存...
async getNewCollection(pds, repo, collection, limit = 10) {
return await atproto.getRecords(pds, repo, `${collection}.new`, limit)
}
}
```
### 2. 新しいPDS対応
```javascript
// src/utils/pds.js を拡張
export function getApiConfig(pds) {
if (pds.includes('syu.is')) {
// 既存の syu.is 設定
} else if (pds.includes('newpds.com')) {
return {
pds: `https://newpds.com`,
bsky: `https://bsky.newpds.com`,
plc: `https://plc.newpds.com`,
web: `https://web.newpds.com`
}
}
// デフォルト設定
}
```
### 3. リアルタイム更新追加
```javascript
// src/hooks/useRealtimeUpdates.js
export function useRealtimeUpdates(collection) {
useEffect(() => {
const ws = new WebSocket('wss://jetstream2.us-east.bsky.network/subscribe')
ws.onmessage = (event) => {
const data = JSON.parse(event.data)
if (data.collection === collection) {
// 新しいレコードを追加
}
}
return () => ws.close()
}, [collection])
}
```

View File

@@ -1,110 +0,0 @@
# 環境変数による機能切り替え
## 概要
開発用機能TestUI、デバッグログをenv変数で簡単に有効/無効化できるようになりました。
## 設定ファイル
### 開発環境用: `.env`
```bash
# Development/Debug features
VITE_ENABLE_TEST_UI=true
VITE_ENABLE_DEBUG=true
```
### 本番環境用: `.env.production`
```bash
# Production settings - Disable development features
VITE_ENABLE_TEST_UI=false
VITE_ENABLE_DEBUG=false
```
## 制御される機能
### 1. TestUI コンポーネント
- **制御**: `VITE_ENABLE_TEST_UI`
- **true**: TestボタンとTestUI表示
- **false**: TestUI関連が完全に非表示
### 2. デバッグログ
- **制御**: `VITE_ENABLE_DEBUG`
- **true**: console.log等が有効
- **false**: すべてのlogが無効化
## 使い方
### 開発時
```bash
# .envで有効化されているので通常通り
npm run dev
npm run build
```
### 本番デプロイ時
```bash
# 自動的に .env.production が読み込まれる
npm run build
# または明示的に指定
NODE_ENV=production npm run build
```
### 手動切り替え
```bash
# 一時的にTestUIだけ無効化
VITE_ENABLE_TEST_UI=false npm run dev
# 一時的にデバッグだけ無効化
VITE_ENABLE_DEBUG=false npm run dev
```
## 実装詳細
### App.jsx
```jsx
// Environment-based feature flags
const ENABLE_TEST_UI = import.meta.env.VITE_ENABLE_TEST_UI === 'true'
const ENABLE_DEBUG = import.meta.env.VITE_ENABLE_DEBUG === 'true'
// TestUI表示制御
{ENABLE_TEST_UI && showTestUI && (
<div className="test-section">
<TestUI />
</div>
)}
// Testボタン表示制御
{ENABLE_TEST_UI && (
<div className="bottom-actions">
<button onClick={() => setShowTestUI(!showTestUI)}>
{showTestUI ? 'close test' : 'test'}
</button>
</div>
)}
```
### logger.js
```jsx
class Logger {
constructor() {
this.isDev = import.meta.env.DEV || false
this.debugEnabled = import.meta.env.VITE_ENABLE_DEBUG === 'true'
this.isEnabled = this.isDev && this.debugEnabled
}
}
```
## メリット
**コード削除不要**: 機能を残したまま本番で無効化
**簡単切り替え**: env変数だけで制御
**自動化対応**: CI/CDで環境別自動ビルド可能
**デバッグ容易**: 必要時に即座に有効化可能
## 本番デプロイチェックリスト
- [ ] `.env.production`でTestUI無効化確認
- [ ] `.env.production`でデバッグ無効化確認
- [ ] 本番ビルドでTestボタン非表示確認
- [ ] 本番でconsole.log出力なし確認

View File

@@ -1,444 +0,0 @@
# OAuth_new 実装ガイド
## Claude Code用実装指示
### 即座に実装可能な改善(優先度:最高)
#### 1. エラーハンドリング強化
**ファイル**: `src/utils/errorHandler.js` (新規作成)
```javascript
export class ATProtoError extends Error {
constructor(message, status, context) {
super(message)
this.status = status
this.context = context
this.timestamp = new Date().toISOString()
}
}
export function getErrorMessage(error) {
if (error.status === 400) {
return 'アカウントまたはコレクションが見つかりません'
} else if (error.status === 429) {
return 'レート制限です。しばらく待ってから再試行してください'
} else if (error.status === 500) {
return 'サーバーエラーが発生しました'
} else if (error.message.includes('NetworkError') || error.message.includes('Failed to fetch')) {
return 'ネットワーク接続を確認してください'
} else if (error.message.includes('timeout')) {
return 'タイムアウトしました。再試行してください'
}
return '予期しないエラーが発生しました'
}
export function logError(error, context) {
console.error(`[ATProto Error] ${context}:`, {
message: error.message,
status: error.status,
timestamp: new Date().toISOString()
})
}
```
**修正**: `src/api/atproto.js`
```javascript
import { ATProtoError, logError } from '../utils/errorHandler.js'
async function request(url, options = {}) {
try {
const response = await fetch(url, options)
if (!response.ok) {
throw new ATProtoError(
`HTTP ${response.status}: ${response.statusText}`,
response.status,
{ url, options }
)
}
return await response.json()
} catch (error) {
if (error instanceof ATProtoError) {
logError(error, 'API Request')
throw error
}
// Network errors
const atprotoError = new ATProtoError(
'ネットワークエラーが発生しました',
0,
{ url, originalError: error.message }
)
logError(atprotoError, 'Network Error')
throw atprotoError
}
}
```
**修正**: `src/hooks/useAdminData.js`
```javascript
import { getErrorMessage, logError } from '../utils/errorHandler.js'
// loadAdminData関数内のcatchブロック
} catch (err) {
logError(err, 'useAdminData.loadAdminData')
setError(getErrorMessage(err))
} finally {
setLoading(false)
}
```
#### 2. シンプルなキャッシュシステム
**ファイル**: `src/utils/cache.js` (新規作成)
```javascript
class SimpleCache {
constructor(ttl = 30000) { // 30秒TTL
this.cache = new Map()
this.ttl = ttl
}
get(key) {
const item = this.cache.get(key)
if (!item) return null
if (Date.now() - item.timestamp > this.ttl) {
this.cache.delete(key)
return null
}
return item.data
}
set(key, data) {
this.cache.set(key, {
data,
timestamp: Date.now()
})
}
clear() {
this.cache.clear()
}
invalidatePattern(pattern) {
for (const key of this.cache.keys()) {
if (key.includes(pattern)) {
this.cache.delete(key)
}
}
}
}
export const dataCache = new SimpleCache()
```
**修正**: `src/api/atproto.js`
```javascript
import { dataCache } from '../utils/cache.js'
export const collections = {
async getBase(pds, repo, collection, limit = 10) {
const cacheKey = `base:${pds}:${repo}:${collection}`
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 = `lang:${pds}:${repo}:${collection}`
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 = `comment:${pds}:${repo}:${collection}`
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
},
// 投稿後にキャッシュをクリア
invalidateCache(collection) {
dataCache.invalidatePattern(collection)
}
}
```
#### 3. ローディングスケルトン
**ファイル**: `src/components/LoadingSkeleton.jsx` (新規作成)
```javascript
import React from 'react'
export default function LoadingSkeleton({ count = 3 }) {
return (
<div className="loading-skeleton">
{Array(count).fill(0).map((_, i) => (
<div key={i} className="skeleton-item">
<div className="skeleton-avatar"></div>
<div className="skeleton-content">
<div className="skeleton-line"></div>
<div className="skeleton-line short"></div>
<div className="skeleton-line shorter"></div>
</div>
</div>
))}
<style jsx>{`
.loading-skeleton {
padding: 10px;
}
.skeleton-item {
display: flex;
padding: 15px;
border: 1px solid #eee;
margin: 10px 0;
border-radius: 8px;
background: #fafafa;
}
.skeleton-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
margin-right: 10px;
flex-shrink: 0;
}
.skeleton-content {
flex: 1;
}
.skeleton-line {
height: 12px;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
margin-bottom: 8px;
border-radius: 4px;
}
.skeleton-line.short {
width: 70%;
}
.skeleton-line.shorter {
width: 40%;
}
@keyframes loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
`}</style>
</div>
)
}
```
**修正**: `src/components/RecordTabs.jsx`
```javascript
import LoadingSkeleton from './LoadingSkeleton.jsx'
// RecordTabsコンポーネント内
{activeTab === 'lang' && (
loading ? (
<LoadingSkeleton count={3} />
) : (
<RecordList
title={pageContext.isTopPage ? "Latest Lang Records" : "Lang Records for this page"}
records={filteredLangRecords}
apiConfig={apiConfig}
/>
)
)}
```
### 中期実装1週間以内
#### 4. リトライ機能
**修正**: `src/api/atproto.js`
```javascript
async function requestWithRetry(url, options = {}, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
return await request(url, options)
} catch (error) {
if (i === maxRetries - 1) throw error
// 429 (レート制限) の場合は長めに待機
const baseDelay = error.status === 429 ? 5000 : 1000
const delay = Math.min(baseDelay * Math.pow(2, i), 30000)
console.log(`Retry ${i + 1}/${maxRetries} after ${delay}ms`)
await new Promise(resolve => setTimeout(resolve, delay))
}
}
}
// 全てのAPI呼び出しでrequestをrequestWithRetryに変更
export const atproto = {
async getDid(pds, handle) {
const res = await requestWithRetry(`https://${pds}/xrpc/${ENDPOINTS.describeRepo}?repo=${handle}`)
return res.did
},
// ...他のメソッドも同様に変更
}
```
#### 5. 段階的ローディング
**修正**: `src/hooks/useAdminData.js`
```javascript
export function useAdminData() {
const [adminData, setAdminData] = useState({
did: '',
profile: null,
records: [],
apiConfig: null
})
const [langRecords, setLangRecords] = useState([])
const [commentRecords, setCommentRecords] = useState([])
const [loadingStates, setLoadingStates] = useState({
admin: true,
base: true,
lang: true,
comment: true
})
const [error, setError] = useState(null)
useEffect(() => {
loadAdminData()
}, [])
const loadAdminData = async () => {
try {
setError(null)
// Phase 1: 管理者情報を最初に取得
setLoadingStates(prev => ({ ...prev, admin: true }))
const apiConfig = getApiConfig(`https://${env.pds}`)
const did = await atproto.getDid(env.pds, env.admin)
const profile = await atproto.getProfile(apiConfig.bsky, did)
setAdminData({ did, profile, records: [], apiConfig })
setLoadingStates(prev => ({ ...prev, admin: false }))
// Phase 2: 基本レコードを取得
setLoadingStates(prev => ({ ...prev, base: true }))
const records = await collections.getBase(apiConfig.pds, did, env.collection)
setAdminData(prev => ({ ...prev, records }))
setLoadingStates(prev => ({ ...prev, base: false }))
// Phase 3: lang/commentを並列取得
const langPromise = collections.getLang(apiConfig.pds, did, env.collection)
.then(data => {
setLangRecords(data)
setLoadingStates(prev => ({ ...prev, lang: false }))
})
.catch(err => {
console.warn('Failed to load lang records:', err)
setLoadingStates(prev => ({ ...prev, lang: false }))
})
const commentPromise = collections.getComment(apiConfig.pds, did, env.collection)
.then(data => {
setCommentRecords(data)
setLoadingStates(prev => ({ ...prev, comment: false }))
})
.catch(err => {
console.warn('Failed to load comment records:', err)
setLoadingStates(prev => ({ ...prev, comment: false }))
})
await Promise.all([langPromise, commentPromise])
} catch (err) {
logError(err, 'useAdminData.loadAdminData')
setError(getErrorMessage(err))
// エラー時もローディング状態を解除
setLoadingStates({
admin: false,
base: false,
lang: false,
comment: false
})
}
}
return {
adminData,
langRecords,
commentRecords,
loading: Object.values(loadingStates).some(Boolean),
loadingStates,
error,
refresh: loadAdminData
}
}
```
### 緊急時対応
#### フォールバック機能
**修正**: `src/hooks/useAdminData.js`
```javascript
// エラー時でも基本機能を維持
const loadWithFallback = async () => {
try {
await loadAdminData()
} catch (err) {
// フォールバック:最低限の表示を維持
setAdminData({
did: env.admin, // ハンドルをDIDとして使用
profile: {
handle: env.admin,
displayName: env.admin,
avatar: null
},
records: [],
apiConfig: getApiConfig(`https://${env.pds}`)
})
setError('一部機能が利用できません。基本表示で継続します。')
}
}
```
## 実装チェックリスト
### Phase 1 (即座実装)
- [ ] `src/utils/errorHandler.js` 作成
- [ ] `src/utils/cache.js` 作成
- [ ] `src/components/LoadingSkeleton.jsx` 作成
- [ ] `src/api/atproto.js` エラーハンドリング追加
- [ ] `src/hooks/useAdminData.js` エラーハンドリング改善
- [ ] `src/components/RecordTabs.jsx` ローディング表示追加
### Phase 2 (1週間以内)
- [ ] `src/api/atproto.js` リトライ機能追加
- [ ] `src/hooks/useAdminData.js` 段階的ローディング実装
- [ ] キャッシュクリア機能の投稿フォーム統合
### テスト項目
- [ ] エラー状態でも最低限表示される
- [ ] キャッシュが適切に動作する
- [ ] ローディング表示が適切に出る
- [ ] リトライが正常に動作する
## パフォーマンス目標
- **初期表示**: 3秒 → 1秒
- **キャッシュヒット率**: 70%以上
- **エラー率**: 10% → 2%以下
- **ユーザー体験**: ローディング状態が常に可視化
この実装により、./oauthで発生している「同じ問題の繰り返し」を避け、
安定した成長可能なシステムが構築できます。

View File

@@ -1,448 +0,0 @@
# OAuth_new 改善計画
## 現状分析
### 良い点
- ✅ クリーンなアーキテクチャHooks分離
- ✅ 公式ライブラリ使用(@atproto/oauth-client-browser
- ✅ 適切なエラーハンドリング
- ✅ 包括的なドキュメント
- ✅ 環境変数による設定外部化
### 問題点
- ❌ パフォーマンス:毎回全データを並列取得
- ❌ UXローディング状態が分かりにくい
- ❌ スケーラビリティ:データ量増加への対応不足
- ❌ エラー詳細度:汎用的すぎるエラーメッセージ
- ❌ リアルタイム性:手動更新が必要
## 改善計画
### Phase 1: 安定性・パフォーマンス向上(優先度:高)
#### 1.1 キャッシュシステム導入
```javascript
// 新規ファイル: src/utils/cache.js
export class DataCache {
constructor(ttl = 30000) { // 30秒TTL
this.cache = new Map()
this.ttl = ttl
}
get(key) {
const item = this.cache.get(key)
if (!item) return null
if (Date.now() - item.timestamp > this.ttl) {
this.cache.delete(key)
return null
}
return item.data
}
set(key, data) {
this.cache.set(key, {
data,
timestamp: Date.now()
})
}
invalidate(pattern) {
for (const key of this.cache.keys()) {
if (key.includes(pattern)) {
this.cache.delete(key)
}
}
}
}
```
#### 1.2 リトライ機能付きAPI
```javascript
// 修正: src/api/atproto.js
async function requestWithRetry(url, options = {}, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
const response = await fetch(url, options)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
return await response.json()
} catch (error) {
if (i === maxRetries - 1) throw error
// 指数バックオフ
const delay = Math.min(1000 * Math.pow(2, i), 10000)
await new Promise(resolve => setTimeout(resolve, delay))
}
}
}
```
#### 1.3 詳細なエラーハンドリング
```javascript
// 新規ファイル: src/utils/errorHandler.js
export class ATProtoError extends Error {
constructor(message, status, context) {
super(message)
this.status = status
this.context = context
this.timestamp = new Date().toISOString()
}
}
export function getErrorMessage(error) {
if (error.status === 400) {
return 'アカウントまたはコレクションが見つかりません'
} else if (error.status === 429) {
return 'レート制限です。しばらく待ってから再試行してください'
} else if (error.status === 500) {
return 'サーバーエラーが発生しました'
} else if (error.message.includes('NetworkError')) {
return 'ネットワーク接続を確認してください'
}
return '予期しないエラーが発生しました'
}
```
### Phase 2: UX改善優先度
#### 2.1 ローディング状態の改善
```javascript
// 修正: src/components/RecordTabs.jsx
const LoadingSkeleton = ({ count = 3 }) => (
<div className="loading-skeleton">
{Array(count).fill(0).map((_, i) => (
<div key={i} className="skeleton-item">
<div className="skeleton-avatar"></div>
<div className="skeleton-content">
<div className="skeleton-line"></div>
<div className="skeleton-line short"></div>
</div>
</div>
))}
</div>
)
// CSS追加
.skeleton-item {
display: flex;
padding: 10px;
border: 1px solid #eee;
margin: 5px 0;
}
.skeleton-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
}
@keyframes loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
```
#### 2.2 インクリメンタルローディング
```javascript
// 修正: src/hooks/useAdminData.js
export function useAdminData() {
const [adminData, setAdminData] = useState({
did: '',
profile: null,
records: [],
apiConfig: null
})
const [langRecords, setLangRecords] = useState([])
const [commentRecords, setCommentRecords] = useState([])
const [loadingStates, setLoadingStates] = useState({
admin: true,
lang: true,
comment: true
})
const loadAdminData = async () => {
try {
// 管理者データを最初に読み込み
setLoadingStates(prev => ({ ...prev, admin: true }))
const apiConfig = getApiConfig(`https://${env.pds}`)
const did = await atproto.getDid(env.pds, env.admin)
const profile = await atproto.getProfile(apiConfig.bsky, did)
setAdminData({ did, profile, records: [], apiConfig })
setLoadingStates(prev => ({ ...prev, admin: false }))
// 基本レコードを読み込み
const records = await collections.getBase(apiConfig.pds, did, env.collection)
setAdminData(prev => ({ ...prev, records }))
// lang/commentを並列で読み込み
const [lang, comment] = await Promise.all([
collections.getLang(apiConfig.pds, did, env.collection)
.finally(() => setLoadingStates(prev => ({ ...prev, lang: false }))),
collections.getComment(apiConfig.pds, did, env.collection)
.finally(() => setLoadingStates(prev => ({ ...prev, comment: false })))
])
setLangRecords(lang)
setCommentRecords(comment)
} catch (err) {
// エラーハンドリング
}
}
return {
adminData,
langRecords,
commentRecords,
loadingStates,
refresh: loadAdminData
}
}
```
### Phase 3: リアルタイム機能(優先度:中)
#### 3.1 WebSocket統合
```javascript
// 新規ファイル: src/hooks/useRealtimeUpdates.js
import { useState, useEffect, useRef } from 'react'
export function useRealtimeUpdates(collection, onNewRecord) {
const [connected, setConnected] = useState(false)
const wsRef = useRef(null)
const reconnectTimeoutRef = useRef(null)
const connect = () => {
try {
wsRef.current = new WebSocket('wss://jetstream2.us-east.bsky.network/subscribe')
wsRef.current.onopen = () => {
setConnected(true)
console.log('WebSocket connected')
// Subscribe to specific collection
wsRef.current.send(JSON.stringify({
type: 'subscribe',
collections: [collection]
}))
}
wsRef.current.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
if (data.collection === collection && data.commit?.operation === 'create') {
onNewRecord(data.commit.record)
}
} catch (err) {
console.warn('Failed to parse WebSocket message:', err)
}
}
wsRef.current.onclose = () => {
setConnected(false)
// Auto-reconnect after 5 seconds
reconnectTimeoutRef.current = setTimeout(connect, 5000)
}
wsRef.current.onerror = (error) => {
console.error('WebSocket error:', error)
setConnected(false)
}
} catch (err) {
console.error('Failed to connect WebSocket:', err)
}
}
useEffect(() => {
connect()
return () => {
if (wsRef.current) {
wsRef.current.close()
}
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current)
}
}
}, [collection])
return { connected }
}
```
#### 3.2 オプティミスティック更新
```javascript
// 修正: src/components/CommentForm.jsx
const handleSubmit = async (e) => {
e.preventDefault()
if (!text.trim() || !url.trim()) return
setLoading(true)
setError(null)
// オプティミスティック更新用の仮レコード
const optimisticRecord = {
uri: `temp-${Date.now()}`,
cid: 'temp',
value: {
$type: env.collection,
url: url.trim(),
comments: [{
url: url.trim(),
text: text.trim(),
author: {
did: user.did,
handle: user.handle,
displayName: user.displayName,
avatar: user.avatar
},
createdAt: new Date().toISOString()
}],
createdAt: new Date().toISOString()
}
}
// UIに即座に反映
if (onOptimisticUpdate) {
onOptimisticUpdate(optimisticRecord)
}
try {
const record = {
repo: user.did,
collection: env.collection,
rkey: `comment-${Date.now()}`,
record: optimisticRecord.value
}
await atproto.putRecord(null, record, agent)
// 成功時はフォームをクリア
setText('')
setUrl('')
if (onCommentPosted) {
onCommentPosted()
}
} catch (err) {
// 失敗時はオプティミスティック更新を取り消し
if (onOptimisticRevert) {
onOptimisticRevert(optimisticRecord.uri)
}
setError(err.message)
} finally {
setLoading(false)
}
}
```
### Phase 4: TypeScript化・テスト優先度
#### 4.1 TypeScript移行
```typescript
// 新規ファイル: src/types/atproto.ts
export interface ATProtoRecord {
uri: string
cid: string
value: {
$type: string
createdAt: string
[key: string]: any
}
}
export interface CommentRecord extends ATProtoRecord {
value: {
$type: string
url: string
comments: Comment[]
createdAt: string
}
}
export interface Comment {
url: string
text: string
author: Author
createdAt: string
}
export interface Author {
did: string
handle: string
displayName?: string
avatar?: string
}
```
#### 4.2 テスト環境
```javascript
// 新規ファイル: src/tests/hooks/useAdminData.test.js
import { renderHook, waitFor } from '@testing-library/react'
import { useAdminData } from '../../hooks/useAdminData'
// Mock API
jest.mock('../../api/atproto', () => ({
atproto: {
getDid: jest.fn(),
getProfile: jest.fn()
},
collections: {
getBase: jest.fn(),
getLang: jest.fn(),
getComment: jest.fn()
}
}))
describe('useAdminData', () => {
test('loads admin data successfully', async () => {
const { result } = renderHook(() => useAdminData())
await waitFor(() => {
expect(result.current.adminData.did).toBeTruthy()
})
})
})
```
## 実装優先順位
### 今すぐ実装すべきPhase 1
1. **エラーハンドリング改善** - 1日で実装可能
2. **キャッシュシステム** - 2日で実装可能
3. **リトライ機能** - 1日で実装可能
### 短期実装1週間以内
1. **ローディングスケルトン** - UX大幅改善
2. **インクリメンタルローディング** - パフォーマンス向上
### 中期実装1ヶ月以内
1. **WebSocketリアルタイム更新** - 新機能
2. **オプティミスティック更新** - UX向上
### 長期実装(必要に応じて)
1. **TypeScript化** - 保守性向上
2. **テスト追加** - 品質保証
## 注意事項
### 既存機能への影響
- すべての改善は後方互換性を保つ
- 段階的実装で破綻リスクを最小化
- 各Phase完了後に動作確認
### パフォーマンス指標
- 初期表示時間: 現在3秒 → 目標1秒
- キャッシュヒット率: 目標70%以上
- エラー率: 現在10% → 目標2%以下
### ユーザビリティ指標
- ローディング状態の可視化
- エラーメッセージの分かりやすさ
- リアルタイム更新の応答性
この改善計画により、oauth_newは./oauthの問題を回避しながら、
より安定した高性能なシステムに進化できます。

View File

@@ -1,81 +0,0 @@
# OAuth認証の修正案
## 現在の問題
1. **スコープエラー**: `Missing required scope: transition:generic`
- OAuth認証時に必要なスコープが不足している
- ✅ 修正済み: `scope: 'atproto transition:generic'` に変更
2. **401エラー**: PDSへの直接アクセス
- `https://shiitake.us-east.host.bsky.network/xrpc/app.bsky.actor.getProfile` で401エラー
- 原因: 個人のPDSに直接アクセスしているが、これは認証が必要
- 解決策: 公開APIエンドポイント`https://public.api.bsky.app`)を使用すべき
3. **セッション保存の問題**: handleが`@unknown`になる
- OAuth認証後にセッションが正しく保存されていない
- ✅ 修正済み: Agentの作成方法を修正
## 修正が必要な箇所
### 1. avatarFetcher.js の修正
個人のPDSではなく、公開APIを使用するように修正
```javascript
// 現在の問題のあるコード
const response = await fetch(`${apiConfig.bsky}/xrpc/app.bsky.actor.getProfile?actor=${did}`)
// 修正案
// PDSに関係なく、常に公開APIを使用
const response = await fetch(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${did}`)
```
### 2. セッション復元の改善
OAuth認証後のコールバック処理で、セッションが正しく復元されていない可能性がある。
```javascript
// restoreSession メソッドの改善
async restoreSession() {
// Try both clients
for (const [name, client] of Object.entries(this.clients)) {
if (!client) continue
const result = await client.init()
if (result?.session) {
// セッション処理を確実に行う
this.agent = new Agent(result.session)
const sessionInfo = await this.processSession(result.session)
// セッション情報をログに出力(デバッグ用)
logger.log('Session restored:', { name, sessionInfo })
return sessionInfo
}
}
return null
}
```
## 根本的な問題
1. **PDSアクセスの誤解**
- `app.bsky.actor.getProfile` は公開API認証不要
- 個人のPDSサーバーに直接アクセスする必要はない
- 常に `https://public.api.bsky.app` を使用すべき
2. **OAuth Clientの初期化タイミング**
- コールバック時に両方のクライアントbsky, syuを試す必要がある
- どちらのPDSでログインしたか分からないため
## 推奨される修正手順
1. **即座の修正**401エラー解決
- `avatarFetcher.js` で公開APIを使用
- `getProfile` 呼び出しをすべて公開APIに変更
2. **セッション管理の改善**
- OAuth認証後のセッション復元を確実に
- エラーハンドリングの強化
3. **デバッグ情報の追加**
- セッション復元時のログ追加
- どのOAuthクライアントが使用されたか確認

View File

@@ -1,601 +0,0 @@
# Phase 1: 即座実装可能な修正
## 1. エラーハンドリング強化30分で実装
### ファイル作成: `src/utils/errorHandler.js`
```javascript
export class ATProtoError extends Error {
constructor(message, status, context) {
super(message)
this.status = status
this.context = context
this.timestamp = new Date().toISOString()
}
}
export function getErrorMessage(error) {
if (!error) return '不明なエラー'
if (error.status === 400) {
return 'アカウントまたはレコードが見つかりません'
} else if (error.status === 401) {
return '認証が必要です。ログインしてください'
} else if (error.status === 403) {
return 'アクセス権限がありません'
} else if (error.status === 429) {
return 'アクセスが集中しています。しばらく待ってから再試行してください'
} else if (error.status === 500) {
return 'サーバーでエラーが発生しました'
} else if (error.message?.includes('fetch')) {
return 'ネットワーク接続を確認してください'
} else if (error.message?.includes('timeout')) {
return 'タイムアウトしました。再試行してください'
}
return `エラーが発生しました: ${error.message || '不明'}`
}
export function logError(error, context = 'Unknown') {
const errorInfo = {
context,
message: error.message,
status: error.status,
timestamp: new Date().toISOString(),
url: window.location.href
}
console.error(`[ATProto Error] ${context}:`, errorInfo)
// 本番環境では外部ログサービスに送信することも可能
// if (import.meta.env.PROD) {
// sendToLogService(errorInfo)
// }
}
```
### 修正: `src/api/atproto.js` のrequest関数
```javascript
import { ATProtoError, logError } from '../utils/errorHandler.js'
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 }
)
logError(timeoutError, 'Request Timeout')
throw timeoutError
}
if (error instanceof ATProtoError) {
logError(error, 'API Request')
throw error
}
// ネットワークエラーなど
const networkError = new ATProtoError(
'ネットワークエラーが発生しました',
0,
{ url, originalError: error.message }
)
logError(networkError, 'Network Error')
throw networkError
}
}
```
### 修正: `src/hooks/useAdminData.js`
```javascript
import { getErrorMessage, logError } from '../utils/errorHandler.js'
export function useAdminData() {
// 既存のstate...
const [error, setError] = useState(null)
const [retryCount, setRetryCount] = useState(0)
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] = await Promise.all([
collections.getBase(apiConfig.pds, did, env.collection),
collections.getLang(apiConfig.pds, did, env.collection),
collections.getComment(apiConfig.pds, did, env.collection)
])
setAdminData({ did, profile, records, apiConfig })
setLangRecords(lang)
setCommentRecords(comment)
setRetryCount(0) // 成功時はリトライカウントをリセット
} catch (err) {
logError(err, 'useAdminData.loadAdminData')
setError(getErrorMessage(err))
// 自動リトライ最大3回
if (retryCount < 3) {
setTimeout(() => {
setRetryCount(prev => prev + 1)
loadAdminData()
}, Math.pow(2, retryCount) * 1000) // 1s, 2s, 4s
}
} finally {
setLoading(false)
}
}
return {
adminData,
langRecords,
commentRecords,
loading,
error,
retryCount,
refresh: loadAdminData
}
}
```
## 2. シンプルキャッシュ15分で実装
### ファイル作成: `src/utils/cache.js`
```javascript
class SimpleCache {
constructor(ttl = 30000) { // 30秒TTL
this.cache = new Map()
this.ttl = ttl
}
generateKey(...parts) {
return parts.filter(Boolean).join(':')
}
get(key) {
const item = this.cache.get(key)
if (!item) return null
if (Date.now() - item.timestamp > this.ttl) {
this.cache.delete(key)
return null
}
console.log(`Cache hit: ${key}`)
return item.data
}
set(key, data) {
this.cache.set(key, {
data,
timestamp: Date.now()
})
console.log(`Cache set: ${key}`)
}
clear() {
this.cache.clear()
console.log('Cache cleared')
}
invalidatePattern(pattern) {
let deletedCount = 0
for (const key of this.cache.keys()) {
if (key.includes(pattern)) {
this.cache.delete(key)
deletedCount++
}
}
console.log(`Cache invalidated: ${pattern} (${deletedCount} items)`)
}
getStats() {
return {
size: this.cache.size,
keys: Array.from(this.cache.keys())
}
}
}
export const dataCache = new SimpleCache()
// デバッグ用:グローバルからアクセス可能にする
if (import.meta.env.DEV) {
window.dataCache = dataCache
}
```
### 修正: `src/api/atproto.js` のcollections
```javascript
import { dataCache } from '../utils/cache.js'
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
},
// 投稿後にキャッシュを無効化
invalidateCache(collection) {
dataCache.invalidatePattern(collection)
}
}
```
### 修正: `src/components/CommentForm.jsx` にキャッシュクリア追加
```javascript
// handleSubmit内の成功時処理に追加
try {
await atproto.putRecord(null, record, agent)
// キャッシュを無効化
collections.invalidateCache(env.collection)
// Clear form
setText('')
setUrl('')
// Notify parent component
if (onCommentPosted) {
onCommentPosted()
}
} catch (err) {
setError(err.message)
}
```
## 3. ローディング改善20分で実装
### ファイル作成: `src/components/LoadingSkeleton.jsx`
```javascript
import React from 'react'
export default function LoadingSkeleton({ count = 3, showTitle = false }) {
return (
<div className="loading-skeleton">
{showTitle && (
<div className="skeleton-title">
<div className="skeleton-line title"></div>
</div>
)}
{Array(count).fill(0).map((_, i) => (
<div key={i} className="skeleton-item">
<div className="skeleton-avatar"></div>
<div className="skeleton-content">
<div className="skeleton-line name"></div>
<div className="skeleton-line text"></div>
<div className="skeleton-line text short"></div>
<div className="skeleton-line meta"></div>
</div>
</div>
))}
<style jsx>{`
.loading-skeleton {
padding: 10px;
}
.skeleton-title {
margin-bottom: 20px;
}
.skeleton-item {
display: flex;
padding: 15px;
border: 1px solid #eee;
margin: 10px 0;
border-radius: 8px;
background: #fafafa;
}
.skeleton-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: skeleton-loading 1.5s infinite;
margin-right: 12px;
flex-shrink: 0;
}
.skeleton-content {
flex: 1;
min-width: 0;
}
.skeleton-line {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: skeleton-loading 1.5s infinite;
margin-bottom: 8px;
border-radius: 4px;
}
.skeleton-line.title {
height: 20px;
width: 30%;
}
.skeleton-line.name {
height: 14px;
width: 25%;
}
.skeleton-line.text {
height: 12px;
width: 90%;
}
.skeleton-line.text.short {
width: 60%;
}
.skeleton-line.meta {
height: 10px;
width: 40%;
margin-bottom: 0;
}
@keyframes skeleton-loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
`}</style>
</div>
)
}
```
### 修正: `src/components/RecordTabs.jsx`
```javascript
import LoadingSkeleton from './LoadingSkeleton.jsx'
export default function RecordTabs({ langRecords, commentRecords, userComments, chatRecords, apiConfig, pageContext }) {
const [activeTab, setActiveTab] = useState('lang')
// ... 既存のロジック
return (
<div className="record-tabs">
<div className="tab-header">
<button
className={`tab-btn ${activeTab === 'lang' ? 'active' : ''}`}
onClick={() => setActiveTab('lang')}
>
Lang Records ({filteredLangRecords?.length || 0})
</button>
<button
className={`tab-btn ${activeTab === 'comment' ? 'active' : ''}`}
onClick={() => setActiveTab('comment')}
>
Comment Records ({filteredCommentRecords?.length || 0})
</button>
<button
className={`tab-btn ${activeTab === 'collection' ? 'active' : ''}`}
onClick={() => setActiveTab('collection')}
>
Collection ({filteredChatRecords?.length || 0})
</button>
<button
className={`tab-btn ${activeTab === 'users' ? 'active' : ''}`}
onClick={() => setActiveTab('users')}
>
User Comments ({filteredUserComments?.length || 0})
</button>
</div>
<div className="tab-content">
{activeTab === 'lang' && (
!langRecords ? (
<LoadingSkeleton count={3} showTitle={true} />
) : (
<RecordList
title={pageContext.isTopPage ? "Latest Lang Records" : "Lang Records for this page"}
records={filteredLangRecords}
apiConfig={apiConfig}
/>
)
)}
{activeTab === 'comment' && (
!commentRecords ? (
<LoadingSkeleton count={3} showTitle={true} />
) : (
<RecordList
title={pageContext.isTopPage ? "Latest Comment Records" : "Comment Records for this page"}
records={filteredCommentRecords}
apiConfig={apiConfig}
/>
)
)}
{activeTab === 'collection' && (
!chatRecords ? (
<LoadingSkeleton count={2} showTitle={true} />
) : (
<RecordList
title={pageContext.isTopPage ? "Latest Collection Records" : "Collection Records for this page"}
records={filteredChatRecords}
apiConfig={apiConfig}
/>
)
)}
{activeTab === 'users' && (
!userComments ? (
<LoadingSkeleton count={3} showTitle={true} />
) : (
<RecordList
title={pageContext.isTopPage ? "Latest User Comments" : "User Comments for this page"}
records={filteredUserComments}
apiConfig={apiConfig}
/>
)
)}
</div>
{/* 既存のstyle... */}
</div>
)
}
```
### 修正: `src/App.jsx` にエラー表示改善
```javascript
import { getErrorMessage } from './utils/errorHandler.js'
export default function App() {
const { user, agent, loading: authLoading, login, logout } = useAuth()
const { adminData, langRecords, commentRecords, loading: dataLoading, error, retryCount, refresh: refreshAdminData } = useAdminData()
const { userComments, chatRecords, loading: userLoading, refresh: refreshUserData } = useUserData(adminData)
const pageContext = usePageContext()
// ... 既存のロジック
if (error) {
return (
<div style={{ padding: '20px', textAlign: 'center' }}>
<h1>ATProto OAuth Demo</h1>
<div style={{
background: '#fee',
color: '#c33',
padding: '15px',
borderRadius: '5px',
margin: '20px 0',
border: '1px solid #fcc'
}}>
<p><strong>エラー:</strong> {error}</p>
{retryCount > 0 && (
<p><small>自動リトライ中... ({retryCount}/3)</small></p>
)}
</div>
<button
onClick={refreshAdminData}
style={{
background: '#007bff',
color: 'white',
border: 'none',
padding: '10px 20px',
borderRadius: '5px',
cursor: 'pointer'
}}
>
再読み込み
</button>
</div>
)
}
// ... 既存のレンダリング
}
```
## 実装チェックリスト
### ✅ Phase 1A: エラーハンドリング30分
- [ ] `src/utils/errorHandler.js` 作成
- [ ] `src/api/atproto.js``request` 関数修正
- [ ] `src/hooks/useAdminData.js` エラーハンドリング追加
- [ ] `src/App.jsx` エラー表示改善
### ✅ Phase 1B: キャッシュ15分
- [ ] `src/utils/cache.js` 作成
- [ ] `src/api/atproto.js``collections` にキャッシュ追加
- [ ] `src/components/CommentForm.jsx` にキャッシュクリア追加
### ✅ Phase 1C: ローディングUI20分
- [ ] `src/components/LoadingSkeleton.jsx` 作成
- [ ] `src/components/RecordTabs.jsx` にローディング表示追加
### テスト
- [ ] エラー状態でも適切にメッセージが表示される
- [ ] キャッシュがコンソールログで確認できる
- [ ] ローディング中にスケルトンが表示される
- [ ] 投稿後にキャッシュがクリアされる
**実装時間目安**: 65分エラーハンドリング30分 + キャッシュ15分 + ローディング20分
これらの修正により、oauth_newは./oauthで頻発している問題を回避し、
より安定したユーザー体験を提供できます。

View File

@@ -1,120 +0,0 @@
# OAuth Comment System 開発進捗 - 2025-06-18
## 今日完了した項目
### ✅ UI改善とスタイリング
1. **ヘッダータイトル削除**: "ai.log"タイトルを削除
2. **ログインボタンアイコン化**: テキストからBlueskyアイコン `<i class="fab fa-bluesky"></i>` に変更
3. **Ask AIボタン削除**: 完全に削除
4. **Testボタン移動**: ページ下部に移動、テキストを小文字に変更
5. **検索バーレイアウト適用**: 認証セクションに検索バーUIパターンを適用
6. **ボーダー削除**: 複数の要素からborder-top, border-bottom削除
7. **ヘッダースペーシング修正**: 左側の余白問題を解決
8. **CSS競合解決**: クラス名に`oauth-`プレフィックス追加でailogサイトとの競合回避
9. **パディング統一**: `padding: 20px 0` に統一(デスクトップ・モバイル共通)
### ✅ 機能実装
1. **テスト用UI作成**: OAuth認証不要のputRecord機能実装
2. **JSONビューワー追加**: コメント表示にshow/hideボタン追加
3. **削除機能追加**: OAuth認証ユーザー用のdeleteボタン実装
4. **動的アバター取得**: 壊れたアバターURL対応のフォールバック機能
5. **ブラウザ動作確認**: 全機能の動作テスト完了
### ✅ 技術的解決
1. **DID処理改善**: テスト用の偽DITエラー修正
2. **Handle処理修正**: 自動`.bsky.social`追加削除
3. **セッション管理**: createSession機能の修正
4. **アバターキャッシュ**: 動的取得とキャッシュ機能実装
## 現在の技術構成
### フロントエンド
- **React + Vite**: モダンなSPA構成
- **ATProto OAuth**: Bluesky認証システム
- **アバター管理**: 動的取得・フォールバック・キャッシュ
- **レスポンシブデザイン**: モバイル・デスクトップ対応
### バックエンド連携
- **ATProto API**: PDS通信
- **Collection管理**: `ai.syui.log.chat.comment`等のレコード操作
- **DID解決**: Handle → DID → PDS → Profile取得
### CSS設計
- **Prefix命名**: `oauth-`で競合回避
- **統一パディング**: `20px 0`でレイアウト統一
- **ailogスタイル継承**: 親サイトとの一貫性保持
## ファイル構成
```
oauth_new/
├── src/
│ ├── App.jsx # メインアプリケーション
│ ├── App.css # 統一スタイルoauth-プレフィックス)
│ ├── components/
│ │ ├── AuthButton.jsx # Blueskyアイコン認証ボタン
│ │ ├── CommentForm.jsx # コメント投稿フォーム
│ │ ├── CommentList.jsx # コメント一覧表示
│ │ └── TestUI.jsx # テスト用UI
│ └── utils/
│ └── avatarFetcher.js # アバター動的取得
├── dist/ # ビルド成果物
├── build-minimal.js # 最小化ビルドスクリプト
└── PROGRESS.md # 本ファイル
```
## 残存課題・継続開発項目
### 🔄 現在進行中
- 特になし(基本機能完成)
### 📋 今後の拡張予定
1. **AI連携強化**
- ai.gptとの統合
- AIコメント自動生成
- 心理分析機能統合
2. **パフォーマンス最適化**
- バンドルサイズ削減現在1.2MB
- 動的インポート実装
- キャッシュ戦略改善
3. **機能拡張**
- リアルタイム更新
- 通知システム
- モデレーション機能
- 多言語対応
4. **ai.log統合**
- 静的ブログジェネレーター連携
- 記事別コメント管理
- SEO最適化
### 🎯 次回セッション予定
1. ai.gpt連携の詳細設計
2. パフォーマンス最適化
3. ai.log本体との統合テスト
## 技術メモ
### 重要な解決方法
- **CSS競合**: `oauth-`プレフィックスで名前空間分離
- **アバター問題**: 3段階フォールバックrecord → fresh fetch → fallback
- **認証フロー**: session管理とDID-based認証
- **レスポンシブ**: 統一パディングでシンプル化
### 設定ファイル連携
- `./my-blog/config.toml`: ブログ設定
- `./oauth/.env.production`: OAuth設定
- `~/.config/syui/ai/log/config.json`: システム設定
## 成果物
**完全に動作するOAuthコメントシステム**
- ATProto認証
- コメント投稿・表示・削除
- アバター表示
- JSON詳細表示
- テスト機能
- レスポンシブデザイン
- ailogサイトとの統合準備完了

View File

@@ -1,222 +0,0 @@
# ATProto OAuth Comment System
ATProtocolBlueskyのOAuth認証を使用したコメントシステムです。
## プロジェクト概要
このプロジェクトは、ATProtocolネットワーク上のコメントとlangレコードを表示するWebアプリケーションです。
- 標準的なOAuth認証画面を使用
- タブ切り替えでレコード表示
- ページコンテキストに応じたフィルタリング
## ファイル構成
```
src/
├── config/
│ └── env.js # 環境変数の一元管理
├── utils/
│ └── pds.js # PDS判定・API設定ユーティリティ
├── api/
│ └── atproto.js # ATProto API クライアント
├── hooks/
│ ├── useAuth.js # OAuth認証フック
│ ├── useAdminData.js # 管理者データ取得フック
│ └── usePageContext.js # ページ判定フック
├── services/
│ └── oauth.js # OAuth認証サービス
├── components/
│ ├── AuthButton.jsx # ログイン/ログアウトボタン
│ ├── RecordTabs.jsx # Lang/Commentタブ切り替え
│ ├── RecordList.jsx # レコード表示リスト
│ ├── UserLookup.jsx # ユーザー検索(未使用)
│ └── OAuthCallback.jsx # OAuth コールバック処理
└── App.jsx # メインアプリケーション
```
## 環境設定
### .env ファイル
```bash
VITE_ADMIN=ai.syui.ai # 管理者ハンドル
VITE_PDS=syu.is # デフォルトPDS
VITE_HANDLE_LIST=["ai.syui.ai", "syui.syui.ai", "ai.ai"] # syu.is系ハンドルリスト
VITE_COLLECTION=ai.syui.log # ベースコレクション
VITE_OAUTH_CLIENT_ID=https://syui.ai/client-metadata.json # OAuth クライアントID
VITE_OAUTH_REDIRECT_URI=https://syui.ai/oauth/callback # OAuth リダイレクトURI
```
### 必要な依存関係
```json
{
"dependencies": {
"@atproto/api": "^0.15.12",
"@atproto/oauth-client-browser": "^0.3.19",
"react": "^18.2.0",
"react-dom": "^18.2.0"
}
}
```
## 主要機能
### 1. OAuth認証システム
**実装場所**: `src/services/oauth.js`
- `@atproto/oauth-client-browser`を使用した標準OAuth実装
- bsky.social と syu.is 両方のPDSに対応
- セッション自動復元機能
**重要**: ATProtoのセッション管理は複雑なため、公式ライブラリの使用が必須です。
### 2. PDS判定システム
**実装場所**: `src/utils/pds.js`
```javascript
// ハンドル判定ロジック
isSyuIsHandle(handle) boolean
// PDS設定取得
getApiConfig(pds) { pds, bsky, plc, web }
```
環境変数`VITE_HANDLE_LIST``VITE_PDS`を基に自動判定します。
### 3. コレクション取得システム
**実装場所**: `src/api/atproto.js`
```javascript
// 基本コレクション
collections.getBase(pds, repo, collection)
// lang コレクション(翻訳系)
collections.getLang(pds, repo, collection) // → {collection}.chat.lang
// comment コレクション(コメント系)
collections.getComment(pds, repo, collection) // → {collection}.chat.comment
```
### 4. ページコンテキスト判定
**実装場所**: `src/hooks/usePageContext.js`
```javascript
// URL解析結果
{
isTopPage: boolean, // トップページかどうか
rkey: string | null, // 個別ページのrkey/posts/xxx → xxx
url: string // 現在のURL
}
```
## 表示ロジック
### フィルタリング
1. **トップページ**: 最新3件を表示
2. **個別ページ**: `record.value.post.url`の rkey が現在ページと一致するもののみ表示
### タブ切り替え
- Lang Records: `{collection}.chat.lang`
- Comment Records: `{collection}.chat.comment`
## 開発・デバッグ
### 起動コマンド
```bash
npm install
npm run dev # 開発サーバー
npm run build # プロダクションビルド
```
### OAuth デバッグ
1. **ローカル開発**: 自動的にloopback clientが使用される
2. **本番環境**: `client-metadata.json`が必要
```json
// public/client-metadata.json
{
"client_id": "https://syui.ai/client-metadata.json",
"client_name": "ATProto Comment System",
"redirect_uris": ["https://syui.ai/oauth/callback"],
"scope": "atproto",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none",
"application_type": "web",
"dpop_bound_access_tokens": true
}
```
### よくある問題
1. **セッションが保存されない**
- `@atproto/oauth-client-browser`のバージョン確認
- IndexedDBの確認ブラウザの開発者ツール
2. **PDS判定が正しく動作しない**
- `VITE_HANDLE_LIST`の JSON 形式を確認
- 環境変数の読み込み確認
3. **レコードが表示されない**
- 管理者アカウントの DID 解決確認
- コレクション名の確認(`{base}.chat.lang`, `{base}.chat.comment`
## API エンドポイント
### 使用しているATProto API
1. **com.atproto.repo.describeRepo**
- ハンドル → DID, PDS解決
2. **app.bsky.actor.getProfile**
- プロフィール情報取得
3. **com.atproto.repo.listRecords**
- コレクションレコード取得
## セキュリティ
- OAuth 2.1 + PKCE による認証
- DPoP (Demonstration of Proof of Possession) 対応
- セッション情報はブラウザのIndexedDBに暗号化保存
## 今後の拡張可能性
1. **コメント投稿機能**
- 認証済みユーザーによるコメント作成
- `com.atproto.repo.putRecord` API使用
2. **リアルタイム更新**
- Jetstream WebSocket 接続
- 新しいレコードの自動表示
3. **マルチPDS対応**
- より多くのPDSへの対応
- 動的PDS判定の改善
## トラブルシューティング
### ログ確認
ブラウザの開発者ツールでコンソールログを確認してください。主要なエラーは以下の通りです:
- `OAuth initialization failed`: OAuth設定の問題
- `Failed to load admin data`: API アクセスエラー
- `Auth check failed`: セッション復元エラー
### 環境変数確認
```javascript
// 開発者ツールのコンソールで確認
console.log(import.meta.env)
```
## 参考資料
- [ATProto OAuth Guide](https://github.com/bluesky-social/atproto/blob/main/packages/api/OAUTH.md)
- [BrowserOAuthClient Documentation](https://github.com/bluesky-social/atproto/tree/main/packages/oauth-client-browser)
- [ATProto API Reference](https://docs.bsky.app/docs/advanced-guides/atproto-api)

View File

@@ -1,41 +0,0 @@
name: Cleanup Old Deployments
on:
workflow_run:
workflows: ["Deploy to Cloudflare Pages"]
types:
- completed
workflow_dispatch:
env:
KEEP_DEPLOYMENTS: 5 # 保持するデプロイメント数
jobs:
cleanup:
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }}
steps:
- name: Cleanup old deployments
run: |
# Get all deployments
DEPLOYMENTS=$(curl -s -X GET \
"https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${{ secrets.CLOUDFLARE_PROJECT_NAME }}/deployments" \
-H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \
-H "Content-Type: application/json")
# Extract deployment IDs (skip the latest N deployments)
DEPLOYMENT_IDS=$(echo "$DEPLOYMENTS" | jq -r ".result | sort_by(.created_on) | reverse | .[${{ env.KEEP_DEPLOYMENTS }}:] | .[].id")
# Delete old deployments
for ID in $DEPLOYMENT_IDS; do
echo "Deleting deployment: $ID"
curl -s -X DELETE \
"https://api.cloudflare.com/client/v4/accounts/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/pages/projects/${{ secrets.CLOUDFLARE_PROJECT_NAME }}/deployments/$ID" \
-H "Authorization: Bearer ${{ secrets.CLOUDFLARE_API_TOKEN }}" \
-H "Content-Type: application/json"
echo "Deleted deployment: $ID"
sleep 1 # Rate limiting
done
echo "Cleanup completed!"

View File

@@ -1,6 +1,6 @@
{ {
"name": "ailog-oauth", "name": "ailog-oauth",
"version": "0.2.2", "version": "0.3.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -8,10 +8,13 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@atproto/api": "^0.15.12",
"@atproto/oauth-client-browser": "^0.3.19",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"@atproto/api": "^0.15.12", "react-markdown": "^9.0.1",
"@atproto/oauth-client-browser": "^0.3.19" "rehype-highlight": "^7.0.2",
"remark-gfm": "^4.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.2.0", "@types/react": "^18.2.0",

View File

@@ -11,6 +11,7 @@
--background: #ffffff; --background: #ffffff;
--background-secondary: #f6f8fa; --background-secondary: #f6f8fa;
--border: #d1d9e0; --border: #d1d9e0;
--border-dark: #b8c0c8;
--hover: rgba(15, 20, 25, 0.1); --hover: rgba(15, 20, 25, 0.1);
} }
@@ -33,22 +34,112 @@ body {
background: var(--background); background: var(--background);
} }
/* Profile Form Styles */
.profile-form-container {
background: var(--background-secondary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.profile-form-container h3 {
margin: 0 0 16px 0;
color: var(--text);
}
.profile-form .form-row {
display: flex;
gap: 16px;
margin-bottom: 16px;
}
.profile-form .form-group {
flex: 1;
}
.profile-form .form-group label {
display: block;
margin-bottom: 6px;
font-weight: 600;
color: var(--text);
}
.profile-form .form-group input,
.profile-form .form-group select,
.profile-form .form-group textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 14px;
transition: border-color 0.2s;
}
.profile-form .form-group input:focus,
.profile-form .form-group select:focus,
.profile-form .form-group textarea:focus {
outline: none;
border-color: var(--primary);
}
.profile-form .form-group textarea {
resize: vertical;
min-height: 80px;
}
.profile-form .submit-btn {
background: var(--primary);
color: white;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: background-color 0.2s;
}
.profile-form .submit-btn:hover:not(:disabled) {
background: var(--primary-hover);
}
.profile-form .submit-btn:disabled {
background: var(--text-secondary);
cursor: not-allowed;
}
/* Profile Record List Styles */
.profile-record-list .record-item.admin {
border-left: 4px solid var(--primary);
}
.profile-record-list .admin-badge {
background: var(--primary);
color: white;
font-size: 10px;
padding: 2px 6px;
border-radius: 10px;
font-weight: 500;
margin-left: 8px;
}
/* Header */ /* Header */
.oauth-app-header { .oauth-app-header {
background: var(--background); background: var(--background);
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 100; z-index: 100;
width: 100%; width: 100%;
} }
.oauth-header-content { .oauth-header-content {
display: flex; /* display: flex; */
justify-content: center; /* justify-content: center; */
align-items: center; /* align-items: center; */
max-width: 800px; max-width: 800px;
margin: 0 auto; margin: 0 auto;
padding: 20px 0; padding: 30px 0;
width: 100%; width: 100%;
} }
@@ -145,7 +236,7 @@ body {
/* Buttons */ /* Buttons */
.btn { .btn {
border: none; border: none;
border-radius: 8px; border-radius: 6px;
font-weight: 700; font-weight: 700;
font-size: 15px; font-size: 15px;
cursor: pointer; cursor: pointer;
@@ -196,25 +287,24 @@ body {
.auth-section { .auth-section {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px;
} }
.auth-section.search-bar-layout { .auth-section.search-bar-layout {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 0; padding: 0;
gap: 0; /* gap: 0; */
width: 100%; width: 100%;
max-width: 400px; /* max-width: 400px; */
} }
.auth-section.search-bar-layout .handle-input { .auth-section.search-bar-layout .handle-input {
flex: 1; flex: 1;
margin: 0; margin: 0;
padding: 10px 15px; padding: 9px 15px;
font-size: 16px; font-size: 13px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px 0 0 8px; border-radius: 4px 0 0 4px;
background: var(--background); background: var(--background);
outline: none; outline: none;
transition: border-color 0.2s; transition: border-color 0.2s;
@@ -228,11 +318,13 @@ body {
} }
.auth-section.search-bar-layout .auth-button { .auth-section.search-bar-layout .auth-button {
border-radius: 0 8px 8px 0; border-radius: 0 4px 4px 0;
border: 1px solid var(--primary); border: 1px solid var(--primary);
border-left: none; border-left: none;
margin: 0; margin: 0;
padding: 10px 15px; padding: 9px 15px;
min-width: 50px;
min-height: 30px;
} }
/* Auth Button */ /* Auth Button */
@@ -240,11 +332,26 @@ body {
background: var(--primary); background: var(--primary);
color: white; color: white;
border: none; border: none;
border-radius: 8px; border-radius: 4px;
padding: 8px 16px; padding: 9px 15px;
font-weight: 700; font-weight: 700;
cursor: pointer; cursor: pointer;
transition: background 0.2s; transition: background 0.2s;
display: flex;
align-items: center;
justify-content: center;
min-width: 50px;
min-height: 30px;
}
/* Loading spinner for auth button */
.auth-button.loading i {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
} }
.auth-button:hover { .auth-button:hover {
@@ -256,11 +363,39 @@ body {
cursor: not-allowed; cursor: not-allowed;
} }
/* Main Content */
.main-content { .main-content {
max-width: 800px; grid-area: main;
margin: 0 auto; max-width: 800px;
padding: 20px 0; 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 { .content-area {
@@ -271,7 +406,7 @@ body {
.card { .card {
background: var(--background); background: var(--background);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 6px;
margin: 16px; margin: 16px;
overflow: hidden; overflow: hidden;
} }
@@ -298,10 +433,6 @@ body {
margin-bottom: 16px; margin-bottom: 16px;
} }
.form-group {
margin-bottom: 16px;
}
.form-group label { .form-group label {
display: block; display: block;
font-weight: 700; font-weight: 700;
@@ -313,7 +444,7 @@ body {
width: 100%; width: 100%;
padding: 12px; padding: 12px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 6px;
font-size: 16px; font-size: 16px;
font-family: inherit; font-family: inherit;
background: var(--background); background: var(--background);
@@ -369,14 +500,29 @@ body {
/* Record List */ /* Record List */
.record-item { .record-item {
border-bottom: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 0;
padding: 16px; padding: 16px;
transition: background 0.2s; transition: background 0.2s, border-color 0.2s;
position: relative; 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 { .record-item:hover {
background: var(--background-secondary); background: var(--background-secondary);
border-color: var(--border-dark);
z-index: 1; /* Bring to front when hovering */
} }
.record-header { .record-header {
@@ -462,7 +608,7 @@ body {
.json-display { .json-display {
margin-top: 12px; margin-top: 12px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 6px;
overflow: hidden; overflow: hidden;
} }
@@ -482,6 +628,8 @@ body {
line-height: 1.4; line-height: 1.4;
overflow-x: auto; overflow-x: auto;
white-space: pre-wrap; white-space: pre-wrap;
word-wrap: break-word;
word-break: break-word;
max-height: 300px; max-height: 300px;
overflow-y: auto; overflow-y: auto;
color: var(--text); color: var(--text);
@@ -490,7 +638,7 @@ body {
/* Ask AI */ /* Ask AI */
.ask-ai-container { .ask-ai-container {
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 6px;
overflow: hidden; overflow: hidden;
background: var(--background); background: var(--background);
} }
@@ -519,13 +667,10 @@ body {
margin-bottom: 16px; margin-bottom: 16px;
} }
.user-message { /*
margin-left: 40px; .user-message { margin-left: 40px; }
} .ai-message { margin-right: 40px; }
*/
.ai-message {
margin-right: 40px;
}
.message-header { .message-header {
display: flex; display: flex;
@@ -537,7 +682,7 @@ body {
.message-content { .message-content {
background: var(--background-secondary); background: var(--background-secondary);
padding: 12px 16px; padding: 12px 16px;
border-radius: 8px; border-radius: 6px;
font-size: 15px; font-size: 15px;
line-height: 1.4; line-height: 1.4;
} }
@@ -561,7 +706,7 @@ body {
.question-input { .question-input {
flex: 1; flex: 1;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 6px;
padding: 12px 16px; padding: 12px 16px;
font-size: 16px; font-size: 16px;
resize: none; resize: none;
@@ -578,7 +723,7 @@ body {
background: var(--primary); background: var(--primary);
color: white; color: white;
border: none; border: none;
border-radius: 8px; border-radius: 6px;
width: 36px; width: 36px;
height: 36px; height: 36px;
cursor: pointer; cursor: pointer;
@@ -600,7 +745,7 @@ body {
/* Test UI */ /* Test UI */
.test-ui { .test-ui {
border: 2px solid var(--danger); border: 2px solid var(--danger);
border-radius: 8px; border-radius: 6px;
margin: 16px; margin: 16px;
background: #fff5f7; background: #fff5f7;
} }
@@ -642,7 +787,7 @@ body {
border: 1px solid #fecaca; border: 1px solid #fecaca;
color: #991b1b; color: #991b1b;
padding: 12px 16px; padding: 12px 16px;
border-radius: 8px; border-radius: 6px;
margin: 16px 0; margin: 16px 0;
} }
@@ -651,7 +796,7 @@ body {
border: 1px solid #bbf7d0; border: 1px solid #bbf7d0;
color: #166534; color: #166534;
padding: 12px 16px; padding: 12px 16px;
border-radius: 8px; border-radius: 6px;
margin: 16px 0; margin: 16px 0;
} }
@@ -683,52 +828,207 @@ body {
} }
/* Responsive */ /* Responsive */
@media (max-width: 768px) { @media (max-width: 1000px) {
.main-content { /* Global mobile constraints */
max-width: 100%; * {
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 { .content-area {
border-left: none; padding: 0px !important;
border-right: none; margin: 0px !important;
width: 100% !important;
max-width: 100% !important;
overflow-x: hidden !important;
} }
.card { .card {
margin: 0; margin: 0px !important;
border-radius: 0; border-radius: 0px !important;
border-left: none; border-left: none !important;
border-right: none; border-right: none !important;
max-width: 100% !important;
} }
.app-header { .card-content {
padding: 8px 16px; padding: 15px !important;
} }
.header-actions { .comment-form {
gap: 4px; padding: 15px !important;
}
.btn {
padding: 6px 12px;
font-size: 14px;
}
.tab-btn {
padding: 12px 16px;
font-size: 14px;
} }
.record-item { .record-item {
padding: 12px 16px; 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-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 { .chat-container {
height: 300px; 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 Mobile */
.oauth-user-profile { .oauth-user-profile {
gap: 8px; 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-section .profile-avatar,
@@ -736,30 +1036,47 @@ body {
width: 36px; width: 36px;
height: 36px; height: 36px;
font-size: 14px; font-size: 14px;
flex-shrink: 0 !important;
} }
.profile-display-name { .profile-display-name {
font-size: 14px; font-size: 14px;
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
max-width: 100% !important;
} }
.profile-handle { .profile-handle {
font-size: 12px; font-size: 12px;
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
max-width: 100% !important;
} }
.profile-did { .profile-did {
font-size: 9px; font-size: 9px;
overflow: hidden !important;
text-overflow: ellipsis !important;
white-space: nowrap !important;
max-width: 100% !important;
} }
.oauth-header-content { .oauth-header-content {
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
align-items: flex-start; /* align-items: flex-start; */
} }
.oauth-header-actions { .oauth-header-actions {
width: 100%; width: 100%;
justify-content: center; justify-content: center;
} }
article.article-content {
max-width: 100%;
}
} }
/* Avatar Styles */ /* Avatar Styles */
@@ -815,7 +1132,7 @@ body {
transform: translateX(-50%); transform: translateX(-50%);
background: var(--background); background: var(--background);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 6px;
padding: 16px; padding: 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
z-index: 1000; z-index: 1000;
@@ -956,3 +1273,211 @@ body {
white-space: nowrap; white-space: nowrap;
border: 0; 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;
word-wrap: anywhere;
}
/* Markdown styles */
.message-content h1,
.message-content h2,
.message-content h3,
.message-content h4,
.message-content h5,
.message-content h6 {
margin: 16px 0 8px 0;
font-weight: 600;
}
.message-content h1 { font-size: 1.5em; }
.message-content h2 { font-size: 1.3em; }
.message-content h3 { font-size: 1.1em; }
.message-content p {
margin: 8px 0;
}
.message-content pre {
background: var(--background-secondary);
border: 1px solid var(--border);
border-radius: 6px;
padding: 12px;
margin: 12px 0;
overflow-x: auto;
}
.message-content code {
background: var(--background-secondary);
padding: 2px 4px;
border-radius: 3px;
font-family: 'Consolas', 'Monaco', 'Andale Mono', 'Ubuntu Mono', monospace;
font-size: 0.9em;
}
.message-content pre code {
background: transparent;
padding: 0;
border-radius: 0;
font-size: 0.9em;
}
.message-content ul,
.message-content ol {
margin: 8px 0;
padding-left: 24px;
}
.message-content li {
margin: 4px 0;
}
.message-content blockquote {
border-left: 4px solid var(--border);
padding-left: 16px;
margin: 12px 0;
color: var(--text-secondary);
}
.message-content table {
border-collapse: collapse;
width: 100%;
margin: 12px 0;
}
.message-content th,
.message-content td {
border: 1px solid var(--border);
padding: 8px 12px;
text-align: left;
}
.message-content th {
background: var(--background-secondary);
font-weight: 600;
}
.message-content a {
color: var(--primary);
text-decoration: none;
}
.message-content a:hover {
text-decoration: underline;
}
.message-content hr {
border: none;
border-top: 1px solid var(--border);
margin: 16px 0;
}
.record-actions {
flex-shrink: 0;
}
.bluesky-footer {
text-align: center;
padding: 20px;
color: var(--primary);
font-size: 24px;
}
.bluesky-footer i {
transition: color 0.2s ease;
}
.bluesky-footer i:hover {
color: var(--primary-hover);
}
/* Custom code block styling */
.message-content pre {
background: #2d3748 !important;
border: 1px solid #4a5568 !important;
border-radius: 6px;
padding: 12px;
margin: 12px 0;
overflow-x: auto;
}
.message-content pre code {
background: transparent !important;
color: #e2e8f0 !important;
font-family: 'Menlo', 'Monaco', 'Consolas', 'Liberation Mono', 'Courier New', monospace;
font-size: 14px;
line-height: 1.5;
}
.message-content code {
background: #2d3748 !important;
color: #e2e8f0 !important;
padding: 2px 4px;
border-radius: 3px;
font-family: 'Menlo', 'Monaco', 'Consolas', 'Liberation Mono', 'Courier New', monospace;
font-size: 14px;
}

View File

@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import { atproto } from './api/atproto.js'
import { useAuth } from './hooks/useAuth.js' import { useAuth } from './hooks/useAuth.js'
import { useAdminData } from './hooks/useAdminData.js' import { useAdminData } from './hooks/useAdminData.js'
import { useUserData } from './hooks/useUserData.js' import { useUserData } from './hooks/useUserData.js'
@@ -6,31 +7,295 @@ import { usePageContext } from './hooks/usePageContext.js'
import AuthButton from './components/AuthButton.jsx' import AuthButton from './components/AuthButton.jsx'
import RecordTabs from './components/RecordTabs.jsx' import RecordTabs from './components/RecordTabs.jsx'
import CommentForm from './components/CommentForm.jsx' import CommentForm from './components/CommentForm.jsx'
import ProfileForm from './components/ProfileForm.jsx'
import AskAI from './components/AskAI.jsx' import AskAI from './components/AskAI.jsx'
import TestUI from './components/TestUI.jsx' import TestUI from './components/TestUI.jsx'
import OAuthCallback from './components/OAuthCallback.jsx' import OAuthCallback from './components/OAuthCallback.jsx'
export default function App() { export default function App() {
const { user, agent, loading: authLoading, login, logout } = useAuth() const { user, agent, loading: authLoading, login, logout } = useAuth()
const { adminData, langRecords, commentRecords, loading: dataLoading, error, retryCount, refresh: refreshAdminData } = useAdminData() const { adminData, langRecords, commentRecords, chatRecords: adminChatRecords, chatHasMore, loading: dataLoading, error, refresh: refreshAdminData, loadMoreChat } = useAdminData()
const { userComments, chatRecords, loading: userLoading, refresh: refreshUserData } = useUserData(adminData) const { userComments, chatRecords, loading: userLoading, refresh: refreshUserData } = useUserData(adminData)
const [userChatRecords, setUserChatRecords] = useState([])
const [userChatLoading, setUserChatLoading] = useState(false)
const pageContext = usePageContext() const pageContext = usePageContext()
const [showAskAI, setShowAskAI] = useState(false) const [showAskAI, setShowAskAI] = useState(false)
const [showTestUI, setShowTestUI] = useState(false) const [showTestUI, setShowTestUI] = useState(false)
// Check if current page has matching chat records (AI posts always have chat records)
const isAiPost = !pageContext.isTopPage && Array.isArray(adminChatRecords) && adminChatRecords.some(chatPair => {
const recordUrl = chatPair.question?.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
}
})
// Environment-based feature flags // Environment-based feature flags
const ENABLE_TEST_UI = import.meta.env.VITE_ENABLE_TEST_UI === 'true' const ENABLE_TEST_UI = import.meta.env.VITE_ENABLE_TEST_UI === 'true'
const ENABLE_DEBUG = import.meta.env.VITE_ENABLE_DEBUG === 'true' const ENABLE_DEBUG = import.meta.env.VITE_ENABLE_DEBUG === 'true'
// Fetch user's own chat records
const fetchUserChatRecords = async () => {
if (!user || !agent) return
setUserChatLoading(true)
try {
const records = await agent.api.com.atproto.repo.listRecords({
repo: user.did,
collection: 'ai.syui.log.chat',
limit: 50
})
// Group questions and answers together
const chatPairs = []
const recordMap = new Map()
// First pass: organize records by base rkey
records.data.records.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))
setUserChatRecords(chatPairs)
} catch (error) {
// Silently fail - no error logging
setUserChatRecords([])
} finally {
setUserChatLoading(false)
}
}
// Fetch user chat records when user/agent changes
useEffect(() => {
fetchUserChatRecords()
}, [user, agent])
// Expose AI profile data to blog's ask-ai.js
useEffect(() => {
if (adminData?.profile) {
// Make AI profile data available globally for ask-ai.js
window.aiProfileData = {
did: adminData.did,
handle: adminData.profile.handle,
displayName: adminData.profile.displayName,
avatar: adminData.profile.avatar
}
// Dispatch event to notify ask-ai.js
window.dispatchEvent(new CustomEvent('aiProfileLoaded', {
detail: window.aiProfileData
}))
}
}, [adminData])
// Expose current user and agent for game page
useEffect(() => {
if (user && agent) {
window.currentUser = user
window.currentAgent = agent
}
}, [user, agent])
// Event listeners for blog communication // Event listeners for blog communication
useEffect(() => { useEffect(() => {
const handleAIQuestion = (event) => { // Clear OAuth completion flag once app is loaded
if (sessionStorage.getItem('oauth_just_completed') === 'true') {
setTimeout(() => {
sessionStorage.removeItem('oauth_just_completed')
}, 1000)
}
const handleAIQuestion = async (event) => {
const { question } = event.detail const { question } = event.detail
if (question && adminData && user && agent) { if (question && adminData && user && agent) {
// Automatically open Ask AI panel and submit question try {
setShowAskAI(true)
// We'll need to pass this to the AskAI component // AI設定
// For now, let's just open the panel const aiConfig = {
host: import.meta.env.VITE_AI_HOST || 'https://ollama.syui.ai',
model: import.meta.env.VITE_AI_MODEL || 'gemma3:1b',
systemPrompt: import.meta.env.VITE_AI_SYSTEM_PROMPT || 'あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。'
}
const prompt = `${aiConfig.systemPrompt}
Question: ${question}
Answer:`
// Ollamaに直接リクエスト
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 30000)
const response = await fetch(`${aiConfig.host}/api/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Origin': 'https://syui.ai',
},
body: JSON.stringify({
model: aiConfig.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()
const answer = data.response || 'エラーが発生しました'
// Save conversation to ATProto
try {
const now = new Date()
const timestamp = now.toISOString()
const rkey = timestamp.replace(/[:.]/g, '-')
// Extract post metadata from current page
const currentUrl = window.location.href
const postSlug = currentUrl.match(/\/posts\/([^/]+)/)?.[1] || ''
const postTitle = document.title.replace(' - syui.ai', '') || ''
// 1. Save question record
const questionRecord = {
$type: 'ai.syui.log.chat',
post: {
url: currentUrl,
slug: postSlug,
title: postTitle,
date: timestamp,
tags: [],
language: "ja"
},
type: "question",
text: question,
author: {
did: user.did,
handle: user.handle,
displayName: user.displayName || user.handle,
avatar: user.avatar
},
createdAt: timestamp
}
await agent.api.com.atproto.repo.putRecord({
repo: user.did,
collection: 'ai.syui.log.chat',
rkey: rkey,
record: questionRecord
})
// 2. Save answer record
const answerRkey = rkey + '-answer'
const answerRecord = {
$type: 'ai.syui.log.chat',
post: {
url: currentUrl,
slug: postSlug,
title: postTitle,
date: timestamp,
tags: [],
language: "ja"
},
type: "answer",
text: answer,
author: {
did: adminData.did,
handle: adminData.profile?.handle,
displayName: adminData.profile?.displayName,
avatar: adminData.profile?.avatar
},
createdAt: timestamp
}
await agent.api.com.atproto.repo.putRecord({
repo: user.did,
collection: 'ai.syui.log.chat',
rkey: answerRkey,
record: answerRecord
})
// Refresh chat records after saving
setTimeout(() => {
fetchUserChatRecords()
}, 1000)
} catch (saveError) {
// Silently fail - no error logging
}
// Send response to blog
window.dispatchEvent(new CustomEvent('aiResponseReceived', {
detail: {
question: question,
answer: answer,
timestamp: new Date().toISOString(),
aiProfile: adminData?.profile ? {
did: adminData.did,
handle: adminData.profile.handle,
displayName: adminData.profile.displayName,
avatar: adminData.profile.avatar
} : null
}
}))
} catch (error) {
// Silently fail - send error response to blog without logging
window.dispatchEvent(new CustomEvent('aiResponseReceived', {
detail: {
question: question,
answer: 'エラーが発生しました。もう一度お試しください。',
timestamp: new Date().toISOString(),
aiProfile: adminData?.profile ? {
did: adminData.did,
handle: adminData.profile.handle,
displayName: adminData.profile.displayName,
avatar: adminData.profile.avatar
} : null
}
}))
}
} }
} }
@@ -67,7 +332,11 @@ export default function App() {
const isLoading = authLoading || dataLoading || userLoading const isLoading = authLoading || dataLoading || userLoading
if (isLoading) { // Don't show loading if we just completed OAuth callback
const isOAuthReturn = window.location.pathname === '/oauth/callback' ||
sessionStorage.getItem('oauth_just_completed') === 'true'
if (isLoading && !isOAuthReturn) {
return ( return (
<div style={{ <div style={{
display: 'flex', display: 'flex',
@@ -100,43 +369,13 @@ export default function App() {
} }
if (error) { if (error) {
return ( // Silently hide component on error - no error display
<div style={{ padding: '20px', textAlign: 'center' }}> return null
<h1>エラー</h1>
<div style={{
background: '#fee',
color: '#c33',
padding: '15px',
borderRadius: '5px',
margin: '20px auto',
maxWidth: '500px',
border: '1px solid #fcc'
}}>
<p><strong>エラー:</strong> {error}</p>
{retryCount > 0 && (
<p><small>自動リトライ中... ({retryCount}/3)</small></p>
)}
</div>
<button
onClick={refreshAdminData}
style={{
background: '#007bff',
color: 'white',
border: 'none',
padding: '10px 20px',
borderRadius: '5px',
cursor: 'pointer',
fontSize: '16px'
}}
>
再読み込み
</button>
</div>
)
} }
return ( return (
<div className="app"> <div className="app">
{!isAiPost && (
<header className="oauth-app-header"> <header className="oauth-app-header">
<div className="oauth-header-content"> <div className="oauth-header-content">
{user && ( {user && (
@@ -177,26 +416,47 @@ export default function App() {
</div> </div>
</div> </div>
</header> </header>
)}
<div className="main-content"> <div className="main-content">
<div className="content-area"> <div className="content-area">
<div className="comment-form"> {user && (
<CommentForm <div className="comment-form">
user={user} <CommentForm
agent={agent} user={user}
onCommentPosted={() => { agent={agent}
refreshAdminData?.() onCommentPosted={() => {
refreshUserData?.() refreshAdminData?.()
}} refreshUserData?.()
/> }}
</div> />
</div>
)}
{user && (
<div className="profile-form">
<ProfileForm
user={user}
agent={agent}
apiConfig={adminData.apiConfig}
onProfilePosted={() => {
refreshAdminData?.()
refreshUserData?.()
}}
/>
</div>
)}
<RecordTabs <RecordTabs
langRecords={langRecords} langRecords={langRecords}
commentRecords={commentRecords} commentRecords={commentRecords}
userComments={userComments} userComments={userComments}
chatRecords={chatRecords} chatRecords={adminChatRecords}
chatHasMore={chatHasMore}
onLoadMoreChat={loadMoreChat}
userChatRecords={userChatRecords}
userChatLoading={userChatLoading}
baseRecords={adminData.records} baseRecords={adminData.records}
apiConfig={adminData.apiConfig} apiConfig={adminData.apiConfig}
pageContext={pageContext} pageContext={pageContext}
@@ -205,6 +465,7 @@ export default function App() {
onRecordDeleted={() => { onRecordDeleted={() => {
refreshAdminData?.() refreshAdminData?.()
refreshUserData?.() refreshUserData?.()
fetchUserChatRecords?.()
}} }}
/> />
@@ -224,6 +485,7 @@ export default function App() {
</button> </button>
</div> </div>
)} )}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,5 @@
// ATProto API client // ATProto API client
import { ATProtoError, logError } from '../utils/errorHandler.js' import { ATProtoError } from '../utils/errorHandler.js'
const ENDPOINTS = { const ENDPOINTS = {
describeRepo: 'com.atproto.repo.describeRepo', describeRepo: 'com.atproto.repo.describeRepo',
@@ -36,12 +36,10 @@ async function request(url, options = {}) {
408, 408,
{ url } { url }
) )
logError(timeoutError, 'Request Timeout')
throw timeoutError throw timeoutError
} }
if (error instanceof ATProtoError) { if (error instanceof ATProtoError) {
logError(error, 'API Request')
throw error throw error
} }
@@ -51,21 +49,20 @@ async function request(url, options = {}) {
0, 0,
{ url, originalError: error.message } { url, originalError: error.message }
) )
logError(networkError, 'Network Error')
throw networkError throw networkError
} }
} }
export const atproto = { export const atproto = {
async getDid(pds, handle) { async getDid(pds, handle) {
const res = await request(`https://${pds}/xrpc/${ENDPOINTS.describeRepo}?repo=${handle}`) const endpoint = pds.startsWith('http') ? pds : `https://${pds}`
const res = await request(`${endpoint}/xrpc/${ENDPOINTS.describeRepo}?repo=${handle}`)
return res.did return res.did
}, },
async getProfile(bsky, actor) { async getProfile(bsky, actor) {
// Skip test DIDs // Skip test DIDs
if (actor && actor.includes('test-')) { if (actor && actor.includes('test-')) {
logger.log('Skipping profile fetch for test DID:', actor)
return { return {
did: actor, did: actor,
handle: 'test.user', handle: 'test.user',
@@ -74,12 +71,28 @@ export const atproto = {
} }
} }
return await request(`${bsky}/xrpc/${ENDPOINTS.getProfile}?actor=${actor}`) // 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) { async getRecords(pds, repo, collection, limit = 10, cursor = null) {
const res = await request(`${pds}/xrpc/${ENDPOINTS.listRecords}?repo=${repo}&collection=${collection}&limit=${limit}`) let url = `${pds}/xrpc/${ENDPOINTS.listRecords}?repo=${repo}&collection=${collection}&limit=${limit}`
return res.records || [] if (cursor) {
url += `&cursor=${cursor}`
}
const res = await request(url)
return {
records: res.records || [],
cursor: res.cursor || null
}
}, },
async searchPlc(plc, did) { async searchPlc(plc, did) {
@@ -115,8 +128,10 @@ export const collections = {
if (cached) return cached if (cached) return cached
const data = await atproto.getRecords(pds, repo, collection, limit) const data = await atproto.getRecords(pds, repo, collection, limit)
dataCache.set(cacheKey, data) // Extract records array for backward compatibility
return data const records = data.records || data
dataCache.set(cacheKey, records)
return records
}, },
async getLang(pds, repo, collection, limit = 10) { async getLang(pds, repo, collection, limit = 10) {
@@ -125,8 +140,10 @@ export const collections = {
if (cached) return cached if (cached) return cached
const data = await atproto.getRecords(pds, repo, `${collection}.chat.lang`, limit) const data = await atproto.getRecords(pds, repo, `${collection}.chat.lang`, limit)
dataCache.set(cacheKey, data) // Extract records array for backward compatibility
return data const records = data.records || data
dataCache.set(cacheKey, records)
return records
}, },
async getComment(pds, repo, collection, limit = 10) { async getComment(pds, repo, collection, limit = 10) {
@@ -135,17 +152,29 @@ export const collections = {
if (cached) return cached if (cached) return cached
const data = await atproto.getRecords(pds, repo, `${collection}.chat.comment`, limit) const data = await atproto.getRecords(pds, repo, `${collection}.chat.comment`, limit)
dataCache.set(cacheKey, data) // Extract records array for backward compatibility
return data const records = data.records || data
dataCache.set(cacheKey, records)
return records
}, },
async getChat(pds, repo, collection, limit = 10) { async getChat(pds, repo, collection, limit = 10, cursor = null) {
// Don't use cache for pagination requests
if (cursor) {
const result = await atproto.getRecords(pds, repo, `${collection}.chat`, limit, cursor)
return result
}
const cacheKey = dataCache.generateKey('chat', pds, repo, collection, limit) const cacheKey = dataCache.generateKey('chat', pds, repo, collection, limit)
const cached = dataCache.get(cacheKey) const cached = dataCache.get(cacheKey)
if (cached) return cached if (cached) {
// Ensure cached data has the correct structure
return Array.isArray(cached) ? { records: cached, cursor: null } : cached
}
const data = await atproto.getRecords(pds, repo, `${collection}.chat`, limit) const data = await atproto.getRecords(pds, repo, `${collection}.chat`, limit)
dataCache.set(cacheKey, data) // Cache only the records array for backward compatibility
dataCache.set(cacheKey, data.records || data)
return data return data
}, },
@@ -155,8 +184,10 @@ export const collections = {
if (cached) return cached if (cached) return cached
const data = await atproto.getRecords(pds, repo, `${collection}.user`, limit) const data = await atproto.getRecords(pds, repo, `${collection}.user`, limit)
dataCache.set(cacheKey, data) // Extract records array for backward compatibility
return data const records = data.records || data
dataCache.set(cacheKey, records)
return records
}, },
async getUserComments(pds, repo, collection, limit = 10) { async getUserComments(pds, repo, collection, limit = 10) {
@@ -165,8 +196,22 @@ export const collections = {
if (cached) return cached if (cached) return cached
const data = await atproto.getRecords(pds, repo, collection, limit) const data = await atproto.getRecords(pds, repo, collection, limit)
dataCache.set(cacheKey, data) // Extract records array for backward compatibility
return data const records = data.records || data
dataCache.set(cacheKey, records)
return records
},
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)
// Extract records array for backward compatibility
const records = data.records || data
dataCache.set(cacheKey, records)
return records
}, },
// 投稿後にキャッシュを無効化 // 投稿後にキャッシュを無効化

View File

@@ -1,4 +1,5 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { logger } from '../utils/logger.js'
export default function AuthButton({ user, onLogin, onLogout, loading }) { export default function AuthButton({ user, onLogin, onLogout, loading }) {
const [handleInput, setHandleInput] = useState('') const [handleInput, setHandleInput] = useState('')
@@ -12,7 +13,7 @@ export default function AuthButton({ user, onLogin, onLogout, loading }) {
try { try {
await onLogin(handleInput.trim()) await onLogin(handleInput.trim())
} catch (error) { } catch (error) {
console.error('Login failed:', error) logger.error('Login failed:', error)
alert('ログインに失敗しました: ' + error.message) alert('ログインに失敗しました: ' + error.message)
} finally { } finally {
setIsLoading(false) setIsLoading(false)
@@ -55,7 +56,7 @@ export default function AuthButton({ user, onLogin, onLogout, loading }) {
type="text" type="text"
value={handleInput} value={handleInput}
onChange={(e) => setHandleInput(e.target.value)} onChange={(e) => setHandleInput(e.target.value)}
placeholder="your.handle.com" placeholder="user.bsky.social"
disabled={isLoading} disabled={isLoading}
className="handle-input" className="handle-input"
onKeyPress={(e) => { onKeyPress={(e) => {
@@ -68,9 +69,9 @@ export default function AuthButton({ user, onLogin, onLogout, loading }) {
type="button" type="button"
onClick={handleSubmit} onClick={handleSubmit}
disabled={isLoading || !handleInput.trim()} disabled={isLoading || !handleInput.trim()}
className="auth-button" className={`auth-button ${isLoading ? 'loading' : ''}`}
> >
{isLoading ? '認証中...' : <i className="fab fa-bluesky"></i>} <i className={isLoading ? "fas fa-spinner" : "fab fa-bluesky"}></i>
</button> </button>
</div> </div>
) )

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import Avatar, { AvatarWithCard, AvatarList } from './Avatar.jsx' import Avatar, { AvatarWithCard, AvatarList } from './Avatar.jsx'
import { getAvatar, batchFetchAvatars, prefetchAvatar } from '../utils/avatar.js' import { getAvatar, batchFetchAvatars, prefetchAvatar } from '../utils/avatar.js'
import { logger } from '../utils/logger.js'
/** /**
* Test component to demonstrate avatar functionality * Test component to demonstrate avatar functionality
@@ -63,7 +64,7 @@ export default function AvatarTest() {
setTestResults(results) setTestResults(results)
} catch (error) { } catch (error) {
console.error('Test failed:', error) logger.error('Test failed:', error)
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -78,7 +79,7 @@ export default function AvatarTest() {
batchResults: Object.fromEntries(avatarMap) batchResults: Object.fromEntries(avatarMap)
})) }))
} catch (error) { } catch (error) {
console.error('Batch test failed:', error) logger.error('Batch test failed:', error)
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -94,7 +95,7 @@ export default function AvatarTest() {
prefetchResult: cachedAvatar prefetchResult: cachedAvatar
})) }))
} catch (error) { } catch (error) {
console.error('Prefetch test failed:', error) logger.error('Prefetch test failed:', error)
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@@ -0,0 +1,231 @@
import React, { useState } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeHighlight from 'rehype-highlight'
import 'highlight.js/styles/github-dark.css'
// 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, chatHasMore, onLoadMoreChat, 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 (
<section>
<p>チャット履歴がありません</p>
</section>
)
}
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 (
<section>
{chatPairs.map((chatPair, i) => (
<div key={chatPair.rkey} className="chat-conversation">
{/* Question */}
{chatPair.question && (
<div className="chat-message user-message comment-style">
<div className="message-header">
{chatPair.question.value.author?.avatar ? (
<img
src={chatPair.question.value.author.avatar}
alt={`${chatPair.question.value.author.displayName || chatPair.question.value.author.handle} avatar`}
className="avatar"
/>
) : (
<div className="avatar">
{(chatPair.question.value.author?.displayName || chatPair.question.value.author?.handle || '?').charAt(0).toUpperCase()}
</div>
)}
<div className="user-info">
<div className="display-name">
{chatPair.question.value.author?.displayName || chatPair.question.value.author?.handle}
{chatPair.question.value.author?.handle === 'syui' && <span className="admin-badge"> Admin</span>}
</div>
<div className="handle">
<a
href={`${getCorrectWebUrl(chatPair.question.value.author?.avatar)}/profile/${chatPair.question.value.author?.did}`}
target="_blank"
rel="noopener noreferrer"
className="handle-link"
>
@{chatPair.question.value.author?.handle}
</a>
</div>
</div>
<div className="record-actions">
<button
onClick={() => toggleJsonView(`${chatPair.rkey}-question`)}
className={`btn btn-sm ${expandedRecords.has(`${chatPair.rkey}-question`) ? 'btn-outline' : 'btn-primary'}`}
title="Show/Hide JSON"
>
{expandedRecords.has(`${chatPair.rkey}-question`) ? 'hide' : 'json'}
</button>
{canDelete(chatPair) && (
<button
onClick={() => handleDelete(chatPair)}
className="btn btn-danger btn-sm"
title="Delete Conversation"
>
delete
</button>
)}
</div>
</div>
{expandedRecords.has(`${chatPair.rkey}-question`) && (
<div className="json-display">
<pre className="json-content">
{JSON.stringify(chatPair.question, null, 2)}
</pre>
</div>
)}
<div className="message-content">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight]}
>
{chatPair.question.value.text}
</ReactMarkdown>
</div>
</div>
)}
{/* Answer */}
{chatPair.answer && (
<div className="chat-message ai-message comment-style">
<div className="message-header">
{chatPair.answer.value.author?.avatar ? (
<img
src={chatPair.answer.value.author.avatar}
alt={`${chatPair.answer.value.author.displayName || chatPair.answer.value.author.handle} avatar`}
className="avatar"
/>
) : (
<div className="avatar">
{(chatPair.answer.value.author?.displayName || chatPair.answer.value.author?.handle || 'AI').charAt(0).toUpperCase()}
</div>
)}
<div className="user-info">
<div className="display-name">
{chatPair.answer.value.author?.displayName || chatPair.answer.value.author?.handle}
</div>
<div className="handle">
<a
href={`${getCorrectWebUrl(chatPair.answer.value.author?.avatar)}/profile/${chatPair.answer.value.author?.did}`}
target="_blank"
rel="noopener noreferrer"
className="handle-link"
>
@{chatPair.answer.value.author?.handle}
</a>
</div>
</div>
<div className="record-actions">
<button
onClick={() => toggleJsonView(`${chatPair.rkey}-answer`)}
className={`btn btn-sm ${expandedRecords.has(`${chatPair.rkey}-answer`) ? 'btn-outline' : 'btn-primary'}`}
title="Show/Hide JSON"
>
{expandedRecords.has(`${chatPair.rkey}-answer`) ? 'hide' : 'json'}
</button>
</div>
</div>
{expandedRecords.has(`${chatPair.rkey}-answer`) && (
<div className="json-display">
<pre className="json-content">
{JSON.stringify(chatPair.answer, null, 2)}
</pre>
</div>
)}
<div className="message-content">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeHighlight]}
>
{chatPair.answer.value.text}
</ReactMarkdown>
</div>
</div>
)}
</div>
))}
{/* Load More Button */}
{chatHasMore && onLoadMoreChat && (
<div className="bluesky-footer">
<i
className="fab fa-bluesky"
onClick={onLoadMoreChat}
style={{cursor: 'pointer'}}
title="続きを読み込む"
></i>
</div>
)}
</section>
)
}

View File

@@ -46,8 +46,13 @@ export default function CommentForm({ user, agent, onCommentPosted }) {
} }
} }
// Post the record // Post the record using the same API as ask-AI
await atproto.putRecord(null, record, agent) await agent.api.com.atproto.repo.putRecord({
repo: record.repo,
collection: record.collection,
rkey: record.rkey,
record: record.record
})
// キャッシュを無効化 // キャッシュを無効化
collections.invalidateCache(env.collection) collections.invalidateCache(env.collection)
@@ -60,6 +65,12 @@ export default function CommentForm({ user, agent, onCommentPosted }) {
onCommentPosted() onCommentPosted()
} }
// Show success message briefly
setText('✓ ')
setTimeout(() => {
setText('')
}, 2000)
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} finally { } finally {
@@ -74,27 +85,27 @@ export default function CommentForm({ user, agent, onCommentPosted }) {
padding: '40px', padding: '40px',
color: 'var(--text-secondary)' color: 'var(--text-secondary)'
}}> }}>
<p>ログインしてコメントを投稿</p> <p>atproto login</p>
</div> </div>
) )
} }
return ( return (
<div> <div>
<h3>コメントを投稿</h3> <h3>post</h3>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="form-group" style={{ marginBottom: '12px', padding: '8px', backgroundColor: 'var(--background-secondary)', borderRadius: '4px', fontSize: '0.9em' }}> <div className="form-group" style={{ marginBottom: '12px', padding: '8px', backgroundColor: 'var(--background-secondary)', borderRadius: '4px', fontSize: '0.9em' }}>
<strong>投稿先:</strong> {window.location.href} <strong>url:</strong> {window.location.href}
</div> </div>
<div className="form-group"> <div className="form-group">
<label htmlFor="comment-text">コメント:</label> <label htmlFor="comment-text">comment:</label>
<textarea <textarea
id="comment-text" id="comment-text"
value={text} value={text}
onChange={(e) => setText(e.target.value)} onChange={(e) => setText(e.target.value)}
placeholder="コメントを入力してください..." placeholder="text..."
rows={4} rows={4}
required required
disabled={loading} disabled={loading}
@@ -104,7 +115,7 @@ export default function CommentForm({ user, agent, onCommentPosted }) {
{error && ( {error && (
<div className="error-message"> <div className="error-message">
エラー: {error} err: {error}
</div> </div>
)} )}
@@ -112,9 +123,9 @@ export default function CommentForm({ user, agent, onCommentPosted }) {
<button <button
type="submit" type="submit"
disabled={loading || !text.trim()} disabled={loading || !text.trim()}
className="btn btn-primary" className={`btn ${loading ? 'btn-outline' : 'btn-primary'}`}
> >
{loading ? '投稿中...' : 'コメントを投稿'} {loading ? 'posting...' : 'post'}
</button> </button>
</div> </div>
</form> </form>

View File

@@ -0,0 +1,166 @@
import React, { useState } from 'react'
import { atproto, collections } from '../api/atproto.js'
import { env } from '../config/env.js'
import { logger } from '../utils/logger.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) {
logger.error('Failed to create profile:', err)
setError(err.message || 'プロフィールの作成に失敗しました')
} finally {
setPosting(false)
}
}
if (!user) {
return null
}
return (
<div className="profile-form-container">
<h3>プロフィール投稿</h3>
{error && (
<div className="error-message">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="profile-form">
<div className="form-row">
<div className="form-group">
<label htmlFor="handle">ハンドル</label>
<input
type="text"
id="handle"
value={handle}
onChange={(e) => setHandle(e.target.value)}
placeholder="例: syui.ai"
required
/>
</div>
<div className="form-group">
<label htmlFor="rkey">Rkey</label>
<input
type="text"
id="rkey"
value={rkey}
onChange={(e) => setRkey(e.target.value)}
placeholder="例: syui"
required
/>
</div>
</div>
<div className="form-group">
<label htmlFor="type">タイプ</label>
<select
id="type"
value={type}
onChange={(e) => setType(e.target.value)}
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<div className="form-group">
<label htmlFor="text">プロフィールテキスト</label>
<textarea
id="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="プロフィールの説明を入力してください"
rows={4}
required
/>
</div>
<button
type="submit"
disabled={posting || !text.trim() || !handle.trim() || !rkey.trim()}
className="submit-btn"
>
{posting ? '投稿中...' : '投稿'}
</button>
</form>
</div>
)
}
export default ProfileForm

View File

@@ -0,0 +1,136 @@
import React, { useState } from 'react'
// Helper function to get correct web URL based on avatar URL
function getCorrectWebUrl(avatarUrl) {
if (!avatarUrl) return 'https://bsky.app'
// If avatar is from bsky.app (main Bluesky), use bsky.app
if (avatarUrl.includes('cdn.bsky.app') || avatarUrl.includes('bsky.app')) {
return 'https://bsky.app'
}
// If avatar is from syu.is, use web.syu.is
if (avatarUrl.includes('bsky.syu.is') || avatarUrl.includes('syu.is')) {
return 'https://syu.is'
}
// Default to bsky.app
return 'https://bsky.app'
}
export default function ProfileRecordList({ profileRecords, apiConfig, user = null, agent = null, onRecordDeleted = null }) {
const [expandedRecords, setExpandedRecords] = useState(new Set())
const toggleJsonView = (uri) => {
const newExpanded = new Set(expandedRecords)
if (newExpanded.has(uri)) {
newExpanded.delete(uri)
} else {
newExpanded.add(uri)
}
setExpandedRecords(newExpanded)
}
if (!profileRecords || profileRecords.length === 0) {
return (
<section>
<p>プロフィールがありません</p>
</section>
)
}
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 (
<section>
{profileRecords.map((profile) => (
<div key={profile.uri} className="chat-message comment-style">
<div className="message-header">
{profile.value.author?.avatar ? (
<img
src={profile.value.author.avatar}
alt={`${profile.value.author.displayName || profile.value.author.handle} avatar`}
className="avatar"
/>
) : (
<div className="avatar">
{(profile.value.author?.displayName || profile.value.author?.handle || '?').charAt(0).toUpperCase()}
</div>
)}
<div className="user-info">
<div className="display-name">
{profile.value.author?.displayName || profile.value.author?.handle}
{profile.value.profileType === 'admin' && (
<span className="admin-badge"> Admin</span>
)}
</div>
<div className="handle">
<a
href={`${getCorrectWebUrl(profile.value.author?.avatar)}/profile/${profile.value.author?.did}`}
target="_blank"
rel="noopener noreferrer"
className="handle-link"
>
@{profile.value.author?.handle}
</a>
</div>
</div>
<div className="record-actions">
<button
onClick={() => toggleJsonView(profile.uri)}
className={`btn btn-sm ${expandedRecords.has(profile.uri) ? 'btn-outline' : 'btn-primary'}`}
title="Show/Hide JSON"
>
{expandedRecords.has(profile.uri) ? 'hide' : 'json'}
</button>
{canDelete(profile) && (
<button
onClick={() => handleDelete(profile)}
className="btn btn-danger btn-sm"
title="Delete Profile"
>
delete
</button>
)}
</div>
</div>
{expandedRecords.has(profile.uri) && (
<div className="json-display">
<pre className="json-content">
{JSON.stringify(profile, null, 2)}
</pre>
</div>
)}
<div className="message-content">{profile.value.text}</div>
</div>
))}
</section>
)
}

View File

@@ -2,6 +2,24 @@ import React, { useState } from 'react'
import AvatarImage from './AvatarImage.jsx' import AvatarImage from './AvatarImage.jsx'
import Avatar from './Avatar.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 }) { export default function RecordList({ title, records, apiConfig, showTitle = true, user = null, agent = null, onRecordDeleted = null }) {
const [expandedRecords, setExpandedRecords] = useState(new Set()) const [expandedRecords, setExpandedRecords] = useState(new Set())
const [deletingRecords, setDeletingRecords] = useState(new Set()) const [deletingRecords, setDeletingRecords] = useState(new Set())
@@ -74,7 +92,7 @@ export default function RecordList({ title, records, apiConfig, showTitle = true
<div className="display-name">{record.value.author?.displayName || record.value.author?.handle}</div> <div className="display-name">{record.value.author?.displayName || record.value.author?.handle}</div>
<div className="handle"> <div className="handle">
<a <a
href={`${apiConfig?.web || 'https://bsky.app'}/profile/${record.value.author?.did}`} href={`${getCorrectWebUrl(record.value.author?.avatar)}/profile/${record.value.author?.did}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="handle-link" className="handle-link"
@@ -107,17 +125,6 @@ export default function RecordList({ title, records, apiConfig, showTitle = true
</div> </div>
</div> </div>
{expandedRecords.has(i) && (
<div className="json-display">
<div className="json-header">json data</div>
<pre className="json-content">
{JSON.stringify(record, null, 2)}
</pre>
</div>
)}
<div className="record-content">{record.value.text || record.value.content}</div>
<div className="record-meta"> <div className="record-meta">
{record.value.post?.url && ( {record.value.post?.url && (
<a <a
@@ -130,6 +137,16 @@ export default function RecordList({ title, records, apiConfig, showTitle = true
</a> </a>
)} )}
</div> </div>
{expandedRecords.has(i) && (
<div className="json-display">
<pre className="json-content">
{JSON.stringify(record, null, 2)}
</pre>
</div>
)}
<div className="record-content">{record.value.text || record.value.content}</div>
</div> </div>
))} ))}
</section> </section>

View File

@@ -1,68 +1,209 @@
import React, { useState } from 'react' import React, { useState, useEffect } from 'react'
import RecordList from './RecordList.jsx' import RecordList from './RecordList.jsx'
import ChatRecordList from './ChatRecordList.jsx'
import ProfileRecordList from './ProfileRecordList.jsx'
import LoadingSkeleton from './LoadingSkeleton.jsx' import LoadingSkeleton from './LoadingSkeleton.jsx'
import { logger } from '../utils/logger.js'
export default function RecordTabs({ langRecords, commentRecords, userComments, chatRecords, baseRecords, apiConfig, pageContext, user = null, agent = null, onRecordDeleted = null }) { export default function RecordTabs({ langRecords, commentRecords, userComments, chatRecords, chatHasMore, onLoadMoreChat, userChatRecords, userChatLoading, baseRecords, apiConfig, pageContext, user = null, agent = null, onRecordDeleted = null }) {
const [activeTab, setActiveTab] = useState('lang') // Check if current page has matching chat records (AI posts always have chat records)
const isAiPost = !pageContext.isTopPage && Array.isArray(chatRecords) && chatRecords.some(chatPair => {
const recordUrl = chatPair.question?.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
}
})
const [activeTab, setActiveTab] = useState(isAiPost ? 'collection' : 'profiles')
// Monitor activeTab changes
useEffect(() => {
logger.log('RecordTabs: activeTab changed to', activeTab)
}, [activeTab])
logger.log('RecordTabs: activeTab is', activeTab)
logger.log('RecordTabs: commentRecords prop:', commentRecords?.length || 0, commentRecords)
// Filter records based on page context // Filter records based on page context
const filterRecords = (records) => { const filterRecords = (records, isProfile = false) => {
// Ensure records is an array
const recordsArray = Array.isArray(records) ? records : []
logger.log('filterRecords called with:', {
recordsLength: recordsArray.length,
isProfile,
isTopPage: pageContext.isTopPage,
pageRkey: pageContext.rkey,
records: recordsArray
})
if (pageContext.isTopPage) { if (pageContext.isTopPage) {
// Top page: show latest 3 records // Top page: show latest 3 records
return records.slice(0, 3) const result = recordsArray.slice(0, 3)
logger.log('filterRecords: Top page result:', result.length, result)
return result
} else { } else {
// Individual page: show records matching the URL // Individual page: show records matching the URL
return records.filter(record => { const filtered = recordsArray.filter(record => {
// Profile records should always be shown
if (isProfile || record.value?.type === 'profile') {
logger.log('filterRecords: Profile record included:', record.value?.type)
return true
}
const recordUrl = record.value?.post?.url const recordUrl = record.value?.post?.url
if (!recordUrl) return false if (!recordUrl) {
logger.log('filterRecords: No recordUrl found for record:', record.value?.type)
return false
}
try { try {
const recordRkey = new URL(recordUrl).pathname.split('/').pop()?.replace(/\.html$/, '') const recordRkey = new URL(recordUrl).pathname.split('/').pop()?.replace(/\.html$/, '')
return recordRkey === pageContext.rkey const matches = recordRkey === pageContext.rkey
logger.log('filterRecords: URL matching:', { recordRkey, pageRkey: pageContext.rkey, matches })
return matches
} catch { } catch {
logger.log('filterRecords: URL parsing failed for:', recordUrl)
return false return false
} }
}) })
logger.log('filterRecords: Individual page result:', filtered.length, filtered)
return filtered
} }
} }
const filteredLangRecords = filterRecords(langRecords) // Special filter for chat records (which are already processed into pairs)
const filteredCommentRecords = filterRecords(commentRecords) const filterChatRecords = (chatPairs) => {
const filteredUserComments = filterRecords(userComments || []) // Ensure chatPairs is an array
const filteredChatRecords = filterRecords(chatRecords || []) const chatArray = Array.isArray(chatPairs) ? chatPairs : []
const filteredBaseRecords = filterRecords(baseRecords || [])
logger.log('filterChatRecords called:', {
isTopPage: pageContext.isTopPage,
rkey: pageContext.rkey,
chatPairsLength: chatArray.length,
chatPairsType: typeof chatPairs,
isArray: Array.isArray(chatPairs)
})
if (pageContext.isTopPage) {
// Top page: show latest 3 pairs
const result = chatArray.slice(0, 3)
logger.log('Top page: returning', result.length, 'pairs')
return result
} else {
// Individual page: show pairs matching the URL (compare path only, ignore domain)
const filtered = chatArray.filter(chatPair => {
const recordUrl = chatPair.question?.value?.post?.url
if (!recordUrl) {
logger.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$/, '')
logger.log('Comparing:', { recordRkey, pageRkey: pageContext.rkey, recordUrl })
// Compare with current page rkey
const matches = recordRkey === pageContext.rkey
if (matches) {
logger.log('Found matching chat pair!')
}
return matches
} catch (error) {
logger.log('Error processing recordUrl:', recordUrl, error)
return false
}
})
logger.log('Individual page: returning', filtered.length, 'filtered pairs')
return filtered
}
}
const filteredLangRecords = filterRecords(Array.isArray(langRecords) ? langRecords : [])
logger.log('RecordTabs: About to filter commentRecords:', commentRecords?.length || 0, commentRecords)
const filteredCommentRecords = filterRecords(Array.isArray(commentRecords) ? commentRecords : [])
logger.log('RecordTabs: After filtering commentRecords:', filteredCommentRecords.length, filteredCommentRecords)
const filteredUserComments = filterRecords(Array.isArray(userComments) ? userComments : [])
const filteredChatRecords = filterChatRecords(Array.isArray(chatRecords) ? chatRecords : [])
const filteredBaseRecords = filterRecords(Array.isArray(baseRecords) ? baseRecords : [])
logger.log('RecordTabs: filtered results:')
logger.log(' - filteredCommentRecords:', filteredCommentRecords.length, filteredCommentRecords)
logger.log(' - filteredLangRecords:', filteredLangRecords.length)
logger.log(' - filteredUserComments:', filteredUserComments.length)
logger.log(' - pageContext:', pageContext)
logger.log('RecordTabs: TAB RENDER VALUES:')
logger.log(' - filteredCommentRecords.length for tab:', filteredCommentRecords.length)
logger.log(' - commentRecords input:', commentRecords?.length || 0)
// Filter profile records from baseRecords
const profileRecords = (Array.isArray(baseRecords) ? 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 ( return (
<div className="record-tabs"> <div className="record-tabs">
{!isAiPost && (
<div className="tab-header"> <div className="tab-header">
<button <button
className={`tab-btn ${activeTab === 'lang' ? 'active' : ''}`} className={`tab-btn ${activeTab === 'profiles' ? 'active' : ''}`}
onClick={() => setActiveTab('lang')} onClick={() => {
logger.log('RecordTabs: Profiles tab clicked')
setActiveTab('profiles')
}}
> >
Lang ({filteredLangRecords.length}) about ({filteredProfileRecords.length})
</button>
<button
className={`tab-btn ${activeTab === 'comment' ? 'active' : ''}`}
onClick={() => setActiveTab('comment')}
>
Comment ({filteredCommentRecords.length})
</button> </button>
<button <button
className={`tab-btn ${activeTab === 'collection' ? 'active' : ''}`} className={`tab-btn ${activeTab === 'collection' ? 'active' : ''}`}
onClick={() => setActiveTab('collection')} onClick={() => setActiveTab('collection')}
> >
Posts ({filteredBaseRecords.length}) chat ({filteredChatRecords.length > 0 ? filteredChatRecords.length : (userChatRecords?.length || 0)})
</button>
<button
className={`tab-btn ${activeTab === 'comment' ? 'active' : ''}`}
onClick={() => {
logger.log('RecordTabs: feedback tab clicked, setting activeTab to comment')
setActiveTab('comment')
}}
>
feedback ({(() => {
logger.log('RecordTabs: feedback tab render - filteredCommentRecords.length:', filteredCommentRecords.length)
return filteredCommentRecords.length
})()})
</button> </button>
<button <button
className={`tab-btn ${activeTab === 'users' ? 'active' : ''}`} className={`tab-btn ${activeTab === 'users' ? 'active' : ''}`}
onClick={() => setActiveTab('users')} onClick={() => setActiveTab('users')}
> >
Users ({filteredUserComments.length}) comment ({filteredUserComments.length})
</button>
<button
className={`tab-btn ${activeTab === 'lang' ? 'active' : ''}`}
onClick={() => setActiveTab('lang')}
>
en ({filteredLangRecords.length})
</button> </button>
</div> </div>
)}
<div className="tab-content"> <div className="tab-content">
{activeTab === 'lang' && ( {activeTab === 'lang' && !isAiPost && (
!langRecords ? ( !langRecords ? (
<LoadingSkeleton count={3} showTitle={true} /> <LoadingSkeleton count={3} showTitle={true} />
) : ( ) : (
@@ -77,7 +218,7 @@ export default function RecordTabs({ langRecords, commentRecords, userComments,
/> />
) )
)} )}
{activeTab === 'comment' && ( {activeTab === 'comment' && !isAiPost && (
!commentRecords ? ( !commentRecords ? (
<LoadingSkeleton count={3} showTitle={true} /> <LoadingSkeleton count={3} showTitle={true} />
) : ( ) : (
@@ -93,21 +234,21 @@ export default function RecordTabs({ langRecords, commentRecords, userComments,
) )
)} )}
{activeTab === 'collection' && ( {activeTab === 'collection' && (
!baseRecords ? ( userChatLoading ? (
<LoadingSkeleton count={2} showTitle={true} /> <LoadingSkeleton count={2} showTitle={true} />
) : ( ) : (
<RecordList <ChatRecordList
title="" chatPairs={filteredChatRecords.length > 0 ? filteredChatRecords : (Array.isArray(userChatRecords) ? userChatRecords : [])}
records={filteredBaseRecords} chatHasMore={filteredChatRecords.length > 0 ? chatHasMore : false}
onLoadMoreChat={filteredChatRecords.length > 0 ? onLoadMoreChat : null}
apiConfig={apiConfig} apiConfig={apiConfig}
user={user} user={user}
agent={agent} agent={agent}
onRecordDeleted={onRecordDeleted} onRecordDeleted={onRecordDeleted}
showTitle={false}
/> />
) )
)} )}
{activeTab === 'users' && ( {activeTab === 'users' && !isAiPost && (
!userComments ? ( !userComments ? (
<LoadingSkeleton count={3} showTitle={true} /> <LoadingSkeleton count={3} showTitle={true} />
) : ( ) : (
@@ -122,6 +263,19 @@ export default function RecordTabs({ langRecords, commentRecords, userComments,
/> />
) )
)} )}
{activeTab === 'profiles' && !isAiPost && (
!baseRecords ? (
<LoadingSkeleton count={3} showTitle={true} />
) : (
<ProfileRecordList
profileRecords={filteredProfileRecords}
apiConfig={apiConfig}
user={user}
agent={agent}
onRecordDeleted={onRecordDeleted}
/>
)
)}
</div> </div>
</div> </div>

View File

@@ -1,6 +1,7 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { atproto } from '../api/atproto.js' import { atproto } from '../api/atproto.js'
import { getPdsFromHandle, getApiConfig } from '../utils/pds.js' import { getPdsFromHandle, getApiConfig } from '../utils/pds.js'
import { logger } from '../utils/logger.js'
export default function UserLookup() { export default function UserLookup() {
const [handleInput, setHandleInput] = useState('') const [handleInput, setHandleInput] = useState('')
@@ -26,7 +27,7 @@ export default function UserLookup() {
config: apiConfig config: apiConfig
}) })
} catch (error) { } catch (error) {
console.error('User lookup failed:', error) logger.error('User lookup failed:', error)
setUserInfo({ error: error.message }) setUserInfo({ error: error.message })
} finally { } finally {
setLoading(false) setLoading(false)

View File

@@ -2,7 +2,8 @@ import { useState, useEffect } from 'react'
import { atproto, collections } from '../api/atproto.js' import { atproto, collections } from '../api/atproto.js'
import { getApiConfig } from '../utils/pds.js' import { getApiConfig } from '../utils/pds.js'
import { env } from '../config/env.js' import { env } from '../config/env.js'
import { getErrorMessage, logError } from '../utils/errorHandler.js' import { getErrorMessage } from '../utils/errorHandler.js'
import { logger } from '../utils/logger.js'
export function useAdminData() { export function useAdminData() {
const [adminData, setAdminData] = useState({ const [adminData, setAdminData] = useState({
@@ -13,9 +14,11 @@ export function useAdminData() {
}) })
const [langRecords, setLangRecords] = useState([]) const [langRecords, setLangRecords] = useState([])
const [commentRecords, setCommentRecords] = useState([]) const [commentRecords, setCommentRecords] = useState([])
const [chatRecords, setChatRecords] = useState([])
const [chatCursor, setChatCursor] = useState(null)
const [chatHasMore, setChatHasMore] = useState(true)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [retryCount, setRetryCount] = useState(0)
useEffect(() => { useEffect(() => {
loadAdminData() loadAdminData()
@@ -30,40 +33,163 @@ export function useAdminData() {
const did = await atproto.getDid(env.pds, env.admin) const did = await atproto.getDid(env.pds, env.admin)
const profile = await atproto.getProfile(apiConfig.bsky, did) const profile = await atproto.getProfile(apiConfig.bsky, did)
// Load all data in parallel // Load all data in parallel with error handling
const [records, lang, comment] = await Promise.all([ logger.log('useAdminData: Starting API calls...')
collections.getBase(apiConfig.pds, did, env.collection), const [records, lang, comment, chatResult] = await Promise.all([
collections.getLang(apiConfig.pds, did, env.collection), collections.getBase(apiConfig.pds, did, env.collection).catch(err => {
collections.getComment(apiConfig.pds, did, env.collection) logger.error('getBase error:', err)
throw err
}),
collections.getLang(apiConfig.pds, did, env.collection).catch(err => {
logger.error('getLang error:', err)
throw err
}),
collections.getComment(apiConfig.pds, did, env.collection).catch(err => {
logger.error('getComment error:', err)
throw err
}),
collections.getChat(apiConfig.pds, did, env.collection, 10).catch(err => {
logger.error('getChat error:', err)
throw err
})
]) ])
logger.log('useAdminData: API calls completed successfully')
const chat = chatResult.records || chatResult
const cursor = chatResult.cursor || null
setChatCursor(cursor)
setChatHasMore(!!cursor)
logger.log('useAdminData: chatResult structure:', chatResult)
logger.log('useAdminData: chat variable type:', typeof chat, 'isArray:', Array.isArray(chat))
// Process chat records into question-answer pairs
const chatPairs = []
const recordMap = new Map()
// Ensure chat is an array
const chatArray = Array.isArray(chat) ? chat : []
// First pass: organize records by base rkey
chatArray.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))
logger.log('useAdminData: raw chat records:', chat.length)
logger.log('useAdminData: processed chat pairs:', chatPairs.length, chatPairs)
logger.log('useAdminData: setting state data:')
logger.log(' - records:', records.length)
logger.log(' - langRecords:', lang.length)
logger.log(' - commentRecords:', comment.length, comment)
logger.log(' - chatRecords:', chatPairs.length)
setAdminData({ did, profile, records, apiConfig }) setAdminData({ did, profile, records, apiConfig })
setLangRecords(lang) setLangRecords(lang)
setCommentRecords(comment) setCommentRecords(comment)
setRetryCount(0) // 成功時はリトライカウントをリセット setChatRecords(chatPairs)
} catch (err) { } catch (err) {
logError(err, 'useAdminData.loadAdminData') // Log the actual error for debugging
setError(getErrorMessage(err)) logger.error('useAdminData: Error in loadAdminData:', err)
setError('silent_failure')
// 自動リトライ最大3回
if (retryCount < 3) {
setTimeout(() => {
setRetryCount(prev => prev + 1)
loadAdminData()
}, Math.pow(2, retryCount) * 1000) // 1s, 2s, 4s
}
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }
const loadMoreChat = async () => {
if (!chatCursor || !chatHasMore) return
try {
const apiConfig = getApiConfig(`https://${env.pds}`)
const did = await atproto.getDid(env.pds, env.admin)
const chatResult = await collections.getChat(apiConfig.pds, did, env.collection, 10, chatCursor)
const newChatRecords = chatResult.records || chatResult
const newCursor = chatResult.cursor || null
// Process new chat records into question-answer pairs
const newChatPairs = []
const recordMap = new Map()
// Ensure newChatRecords is an array
const newChatArray = Array.isArray(newChatRecords) ? newChatRecords : []
// First pass: organize records by base rkey
newChatArray.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) {
newChatPairs.push({
rkey,
question: pair.question,
answer: pair.answer,
createdAt: pair.question.value.createdAt
})
}
})
// Sort new pairs by creation time (newest first)
newChatPairs.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
// Append to existing chat records
setChatRecords(prev => [...prev, ...newChatPairs])
setChatCursor(newCursor)
setChatHasMore(!!newCursor)
} catch (err) {
// Silently fail - no error logging
}
}
return { return {
adminData, adminData,
langRecords, langRecords,
commentRecords, commentRecords,
chatRecords,
chatHasMore,
loading, loading,
error, error,
retryCount, refresh: loadAdminData,
refresh: loadAdminData loadMoreChat
} }
} }

View File

@@ -3,18 +3,15 @@ import { atproto, collections } from '../api/atproto.js'
import { env } from '../config/env.js' import { env } from '../config/env.js'
import { logger } from '../utils/logger.js' import { logger } from '../utils/logger.js'
import { getErrorMessage, logError } from '../utils/errorHandler.js' import { getErrorMessage, logError } from '../utils/errorHandler.js'
import { AIProviderFactory } from '../services/aiProvider.js'
export function useAskAI(adminData, userProfile, agent) { export function useAskAI(adminData, userProfile, agent) {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState(null) const [error, setError] = useState(null)
const [chatHistory, setChatHistory] = useState([]) const [chatHistory, setChatHistory] = useState([])
// AI設定を環境変数から取得 // AIプロバイダーを環境変数から作成
const aiConfig = { const aiProvider = AIProviderFactory.createFromEnv()
host: import.meta.env.VITE_AI_HOST || 'https://ollama.syui.ai',
model: import.meta.env.VITE_AI_MODEL || 'gemma3:1b',
systemPrompt: import.meta.env.VITE_AI_SYSTEM_PROMPT || 'あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。'
}
const askQuestion = async (question) => { const askQuestion = async (question) => {
if (!question.trim()) return if (!question.trim()) return
@@ -23,47 +20,13 @@ export function useAskAI(adminData, userProfile, agent) {
setError(null) setError(null)
try { try {
logger.log('Sending question to Ollama:', question) logger.log('Sending question to AI provider:', question)
// Ollamaに直接リクエスト送信oauth_oldと同じ方式 // AIプロバイダーに質問を送信
const prompt = `${aiConfig.systemPrompt} const aiResponse = await aiProvider.ask(question, {
userProfile: userProfile
Question: ${question}
Answer:`
// Add timeout to fetch request
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 30000) // 30 second timeout
const response = await fetch(`${aiConfig.host}/api/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Origin': 'https://syui.ai',
},
body: JSON.stringify({
model: aiConfig.model,
prompt: prompt,
stream: false,
options: {
temperature: 0.9,
top_p: 0.9,
num_predict: 200, // Longer responses for better answers
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()
const aiResponse = { answer: data.response || 'エラーが発生しました' }
logger.log('Received AI response:', aiResponse) logger.log('Received AI response:', aiResponse)
// AI回答をチャット履歴に追加 // AI回答をチャット履歴に追加
@@ -106,10 +69,10 @@ Answer:`
logError(err, 'useAskAI.askQuestion') logError(err, 'useAskAI.askQuestion')
let errorMessage = 'AI応答の生成に失敗しました' let errorMessage = 'AI応答の生成に失敗しました'
if (err.name === 'AbortError') { if (err.message.includes('Request timeout')) {
errorMessage = 'AI応答がタイムアウトしました30秒' errorMessage = 'AI応答がタイムアウトしました'
} else if (err.message.includes('Ollama API error')) { } else if (err.message.includes('API error')) {
errorMessage = `Ollama API エラー: ${err.message}` errorMessage = `API エラー: ${err.message}`
} else if (err.message.includes('Failed to fetch')) { } else if (err.message.includes('Failed to fetch')) {
errorMessage = 'AI サーバーに接続できませんでした' errorMessage = 'AI サーバーに接続できませんでした'
} }

View File

@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { OAuthService } from '../services/oauth.js' import { OAuthService } from '../services/oauth.js'
import { logger } from '../utils/logger.js'
const oauthService = new OAuthService() const oauthService = new OAuthService()
@@ -21,7 +22,7 @@ export function useAuth() {
// If we're on callback page and authentication succeeded, notify parent // If we're on callback page and authentication succeeded, notify parent
if (window.location.pathname === '/oauth/callback') { if (window.location.pathname === '/oauth/callback') {
console.log('OAuth callback completed, notifying parent window') logger.log('OAuth callback completed, notifying parent window')
// Get referrer or use stored return URL // Get referrer or use stored return URL
const returnUrl = sessionStorage.getItem('oauth_return_url') || const returnUrl = sessionStorage.getItem('oauth_return_url') ||
@@ -38,6 +39,8 @@ export function useAuth() {
user: authResult.user user: authResult.user
}, '*') }, '*')
} else { } else {
// Set flag to skip loading screen after redirect
sessionStorage.setItem('oauth_just_completed', 'true')
// Direct redirect // Direct redirect
setTimeout(() => { setTimeout(() => {
window.location.href = returnUrl window.location.href = returnUrl
@@ -46,7 +49,7 @@ export function useAuth() {
} }
} }
} catch (error) { } catch (error) {
console.error('Auth initialization failed:', error) logger.error('Auth initialization failed:', error)
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@@ -1,7 +1,8 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { atproto, collections } from '../api/atproto.js' import { atproto, collections } from '../api/atproto.js'
import { getApiConfig, isSyuIsHandle } from '../utils/pds.js' import { getApiConfig, isSyuIsHandle, getPdsFromHandle } from '../utils/pds.js'
import { env } from '../config/env.js' import { env } from '../config/env.js'
import { logger } from '../utils/logger.js'
export function useUserData(adminData) { export function useUserData(adminData) {
const [userComments, setUserComments] = useState([]) const [userComments, setUserComments] = useState([])
@@ -24,13 +25,53 @@ export function useUserData(adminData) {
env.collection env.collection
) )
// 2. Get chat records from ai.syui.log.chat // 2. Get chat records from ai.syui.log.chat and process into pairs
const chatRecords = await collections.getChat( const chatResult = await collections.getChat(
adminData.apiConfig.pds, adminData.apiConfig.pds,
adminData.did, adminData.did,
env.collection env.collection
) )
setChatRecords(chatRecords)
const chatRecords = chatResult.records || chatResult
logger.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))
logger.log('useUserData: processed chatPairs:', chatPairs.length, chatPairs)
setChatRecords(chatPairs)
// 3. Get base collection records which contain user comments // 3. Get base collection records which contain user comments
const baseRecords = await collections.getBase( const baseRecords = await collections.getBase(
@@ -62,7 +103,7 @@ export function useUserData(adminData) {
// Also try to get individual user records from the user list // Also try to get individual user records from the user list
// Currently skipping user list processing since users contain placeholder DIDs // Currently skipping user list processing since users contain placeholder DIDs
if (userListRecords.length > 0 && userListRecords[0].value?.users) { if (userListRecords.length > 0 && userListRecords[0].value?.users) {
console.log('User list found, but skipping placeholder users for now') logger.log('User list found, but skipping placeholder users for now')
// Filter out placeholder users // Filter out placeholder users
const realUsers = userListRecords[0].value.users.filter(user => const realUsers = userListRecords[0].value.users.filter(user =>
@@ -73,7 +114,7 @@ export function useUserData(adminData) {
) )
if (realUsers.length > 0) { if (realUsers.length > 0) {
console.log(`Processing ${realUsers.length} real users`) logger.log(`Processing ${realUsers.length} real users`)
for (const user of realUsers) { for (const user of realUsers) {
const userHandle = user.handle const userHandle = user.handle
@@ -88,14 +129,21 @@ export function useUserData(adminData) {
userPds = user.pds.replace('https://', '') userPds = user.pds.replace('https://', '')
userApiConfig = getApiConfig(userPds) userApiConfig = getApiConfig(userPds)
} else { } else {
// Auto-detect PDS based on handle and get real DID // Always get actual PDS from describeRepo first
if (isSyuIsHandle(userHandle)) { 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
logger.warn(`Failed to get PDS for ${userHandle} from bsky.social, trying syu.is:`, error)
userPds = env.pds userPds = env.pds
userApiConfig = getApiConfig(userPds) userApiConfig = getApiConfig(env.pds)
userDid = await atproto.getDid(userPds, userHandle)
} else {
userPds = 'bsky.social'
userApiConfig = getApiConfig(userPds)
userDid = await atproto.getDid(userPds, userHandle) userDid = await atproto.getDid(userPds, userHandle)
} }
} }
@@ -117,7 +165,7 @@ export function useUserData(adminData) {
try { try {
profile = await atproto.getProfile(userApiConfig.bsky, userDid) profile = await atproto.getProfile(userApiConfig.bsky, userDid)
} catch (profileError) { } catch (profileError) {
console.warn(`Failed to get profile for ${userHandle}:`, profileError) logger.warn(`Failed to get profile for ${userHandle}:`, profileError)
} }
// Add profile info to each record // Add profile info to each record
@@ -136,11 +184,11 @@ export function useUserData(adminData) {
allUserComments.push(...enrichedRecords) allUserComments.push(...enrichedRecords)
} catch (userError) { } catch (userError) {
console.warn(`Failed to fetch data for user ${userHandle}:`, userError) logger.warn(`Failed to fetch data for user ${userHandle}:`, userError)
} }
} }
} else { } else {
console.log('No real users found in user list - all appear to be placeholders') logger.log('No real users found in user list - all appear to be placeholders')
} }
} }

View File

@@ -3,4 +3,8 @@ import ReactDOM from 'react-dom/client'
import App from './App' import App from './App'
import './App.css' import './App.css'
ReactDOM.createRoot(document.getElementById('comment-atproto')).render(<App />) // Only mount the OAuth app if the target element exists
const targetElement = document.getElementById('comment-atproto')
if (targetElement) {
ReactDOM.createRoot(targetElement).render(<App />)
}

View File

@@ -0,0 +1,214 @@
/**
* AI Provider Abstract Interface
* Supports multiple AI backends (Ollama, Claude, etc.)
*/
export class AIProvider {
constructor(config) {
this.config = config
}
/**
* Send a question to the AI and get a response
* @param {string} question - User's question
* @param {Object} context - Additional context (user info, etc.)
* @returns {Promise<{answer: string}>}
*/
async ask(question, context = {}) {
throw new Error('ask() method must be implemented by subclass')
}
/**
* Check if the provider is available
* @returns {Promise<boolean>}
*/
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)
}
}

View File

@@ -2,6 +2,7 @@ import { BrowserOAuthClient } from '@atproto/oauth-client-browser'
import { Agent } from '@atproto/api' import { Agent } from '@atproto/api'
import { env } from '../config/env.js' import { env } from '../config/env.js'
import { isSyuIsHandle } from '../utils/pds.js' import { isSyuIsHandle } from '../utils/pds.js'
import { logger } from '../utils/logger.js'
export class OAuthService { export class OAuthService {
constructor() { constructor() {
@@ -44,7 +45,7 @@ export class OAuthService {
// Try to restore session // Try to restore session
return await this.restoreSession() return await this.restoreSession()
} catch (error) { } catch (error) {
console.error('OAuth initialization failed:', error) logger.error('OAuth initialization failed:', error)
this.initPromise = null this.initPromise = null
throw error throw error
} }
@@ -89,18 +90,18 @@ export class OAuthService {
displayName = profile.data.displayName || null displayName = profile.data.displayName || null
avatar = profile.data.avatar || null avatar = profile.data.avatar || null
console.log('Profile fetched from session:', { logger.log('Profile fetched from session:', {
did, did,
handle, handle,
displayName, displayName,
avatar: avatar ? 'present' : 'none' avatar: avatar ? 'present' : 'none'
}) })
} catch (error) { } catch (error) {
console.log('Failed to get profile from session:', error) logger.log('Failed to get profile from session:', error)
// Keep the basic info we have // Keep the basic info we have
} }
} else if (did && did.includes('test-')) { } else if (did && did.includes('test-')) {
console.log('Skipping profile fetch for test DID:', did) logger.log('Skipping profile fetch for test DID:', did)
} }
this.sessionInfo = { this.sessionInfo = {
@@ -140,7 +141,7 @@ export class OAuthService {
} }
return null return null
} catch (error) { } catch (error) {
console.error('Auth check failed:', error) logger.error('Auth check failed:', error)
return null return null
} }
} }
@@ -168,7 +169,7 @@ export class OAuthService {
// Reload page // Reload page
window.location.reload() window.location.reload()
} catch (error) { } catch (error) {
console.error('Logout failed:', error) logger.error('Logout failed:', error)
} }
} }

View File

@@ -3,7 +3,7 @@ class Logger {
constructor() { constructor() {
this.isDev = import.meta.env.DEV || false this.isDev = import.meta.env.DEV || false
this.debugEnabled = import.meta.env.VITE_ENABLE_DEBUG === 'true' this.debugEnabled = import.meta.env.VITE_ENABLE_DEBUG === 'true'
this.isEnabled = this.isDev && this.debugEnabled // Enable only in dev AND when debug flag is true this.isEnabled = this.debugEnabled // Enable when debug flag is true (regardless of dev mode)
} }
log(...args) { log(...args) {
@@ -76,7 +76,7 @@ class Logger {
// シングルトンインスタンス // シングルトンインスタンス
export const logger = new Logger() export const logger = new Logger()
// 開発環境でのみグローバルアクセス可能にする // デバッグ有効時にグローバルアクセス可能にする
if (import.meta.env.DEV && import.meta.env.VITE_ENABLE_DEBUG === 'true') { if (import.meta.env.VITE_ENABLE_DEBUG === 'true') {
window._logger = logger window._logger = logger
} }

View File

@@ -1,13 +1,16 @@
import { env } from '../config/env.js' import { env } from '../config/env.js'
// PDS判定からAPI設定を取得 // PDS判定からAPI設定を取得 - 実際のPDSエンドポイントに基づいて設定
export function getApiConfig(pds) { export function getApiConfig(pds) {
if (pds.includes(env.pds)) { // pdsからhttps://を除去してドメインのみ取得
const cleanPds = pds.replace(/^https?:\/\//, '')
if (cleanPds.includes(env.pds)) {
return { return {
pds: `https://${env.pds}`, pds: `https://${env.pds}`,
bsky: `https://bsky.${env.pds}`, bsky: `https://bsky.${env.pds}`,
plc: `https://plc.${env.pds}`, plc: `https://plc.${env.pds}`,
web: `https://web.${env.pds}` web: `https://${env.pds}`
} }
} }
return { return {

12
pds/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AT URI Browser - syui.ai</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

27
pds/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "pds-browser",
"version": "0.3.1",
"description": "AT Protocol browser for ai.log",
"main": "index.js",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"license": "MIT",
"dependencies": {
"@atproto/api": "^0.13.0",
"@atproto/did": "^0.1.0",
"@atproto/lexicon": "^0.4.0",
"@atproto/syntax": "^0.3.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/react": "^18.0.37",
"@types/react-dom": "^18.0.11",
"@vitejs/plugin-react": "^4.0.0",
"vite": "^5.0.0"
}
}

128
pds/src/App.css Normal file
View File

@@ -0,0 +1,128 @@
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 20px;
background-color: #f5f5f5;
line-height: 1.6;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
padding: 30px;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
h1 {
color: #333;
margin-bottom: 30px;
border-bottom: 3px solid #007acc;
padding-bottom: 10px;
}
.test-section {
margin-bottom: 30px;
padding: 20px;
background: #f8f9fa;
border-radius: 8px;
border-left: 4px solid #007acc;
}
.test-uris {
background: #fff;
padding: 15px;
border-radius: 5px;
border: 1px solid #ddd;
margin: 15px 0;
}
.at-uri {
font-family: 'Monaco', 'Consolas', monospace;
background: #f4f4f4;
padding: 8px 12px;
border-radius: 4px;
margin: 10px 0;
display: block;
word-break: break-all;
cursor: pointer;
transition: background-color 0.2s;
}
.at-uri:hover {
background: #e8e8e8;
}
.instructions {
background: #e8f4f8;
padding: 15px;
border-radius: 5px;
margin: 15px 0;
}
.instructions ol {
margin: 10px 0;
padding-left: 20px;
}
.back-link {
display: inline-block;
margin-top: 20px;
color: #007acc;
text-decoration: none;
font-weight: bold;
}
.back-link:hover {
text-decoration: underline;
}
/* AT Browser Modal Styles */
.at-uri-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.at-uri-modal-content {
background-color: white;
border-radius: 8px;
max-width: 800px;
max-height: 600px;
width: 90%;
height: 80%;
overflow: auto;
position: relative;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.at-uri-modal-close {
position: absolute;
top: 10px;
right: 10px;
background: none;
border: none;
font-size: 20px;
cursor: pointer;
z-index: 1001;
padding: 5px 10px;
}
/* AT URI Link Styles */
[data-at-uri] {
color: #1976d2;
cursor: pointer;
text-decoration: underline;
}
[data-at-uri]:hover {
color: #1565c0;
}

62
pds/src/App.jsx Normal file
View File

@@ -0,0 +1,62 @@
import React from 'react'
import { AtUriBrowser } from './components/AtUriBrowser.jsx'
import './App.css'
function App() {
return (
<AtUriBrowser>
<div className="container">
<h1>AT URI Browser</h1>
<div className="test-section">
<h2>テスト用 AT URI</h2>
<p>以下のAT URIをクリックするとモーダルでコンテンツが表示されます</p>
<div className="test-uris">
<div className="at-uri" data-at-uri="at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/app.bsky.feed.post/3lu5givmkc222">
at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/app.bsky.feed.post/3lu5givmkc222
</div>
<div className="at-uri" data-at-uri="at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/app.bsky.actor.profile/self">
at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/app.bsky.actor.profile/self
</div>
<div className="at-uri" data-at-uri="at://syui.ai/app.bsky.actor.profile/self">
at://syui.ai/app.bsky.actor.profile/self
</div>
<div className="at-uri" data-at-uri="at://bsky.app/app.bsky.actor.profile/self">
at://bsky.app/app.bsky.actor.profile/self
</div>
</div>
<div className="instructions">
<h3>使用方法:</h3>
<ol>
<li>上記のAT URIをクリックしてください</li>
<li>モーダルがポップアップしAT Protocolレコードの内容が表示されます</li>
<li>モーダルは×ボタンまたはEscキーで閉じることができます</li>
<li>モーダルはレスポンシブ対応で異なる画面サイズに対応します</li>
</ol>
</div>
</div>
<div className="test-section">
<h2>AT URI について</h2>
<p>AT URIはAT Protocolで使用される統一リソース識別子ですこの形式により分散ソーシャルネットワーク上のコンテンツを一意に識別できます</p>
<p>このブラウザを使用することでブログ投稿やその他のコンテンツに埋め込まれたAT URIを直接探索することが可能です</p>
<h3>対応PDS環境</h3>
<ul>
<li><strong>bsky.social</strong> - メインのBlueskyネットワーク</li>
<li><strong>syu.is</strong> - 独立したPDS環境</li>
<li><strong>plc.directory</strong> + <strong>plc.syu.is</strong> - DID解決</li>
</ul>
<p><small>注意: 独立したPDS環境ではレコードの同期状況により一部のコンテンツが利用できない場合があります</small></p>
</div>
<a href="/" className="back-link"> ブログに戻る</a>
</div>
</AtUriBrowser>
)
}
export default App

View File

@@ -0,0 +1,75 @@
/*
* AT URI Browser Component
* Copyright (c) 2025 ai.log
* MIT License
*/
import React, { useState, useEffect } from 'react'
import { AtUriModal } from './AtUriModal.jsx'
import { isAtUri } from '../lib/atproto.js'
export function AtUriBrowser({ children }) {
const [modalUri, setModalUri] = useState(null)
useEffect(() => {
const handleAtUriClick = (e) => {
const target = e.target
// Check if clicked element has at-uri data attribute
if (target.dataset.atUri) {
e.preventDefault()
setModalUri(target.dataset.atUri)
return
}
// Check if clicked element contains at-uri text
const text = target.textContent
if (text && isAtUri(text)) {
e.preventDefault()
setModalUri(text)
return
}
// Check if parent element has at-uri
const parent = target.parentElement
if (parent && parent.dataset.atUri) {
e.preventDefault()
setModalUri(parent.dataset.atUri)
return
}
}
document.addEventListener('click', handleAtUriClick)
return () => {
document.removeEventListener('click', handleAtUriClick)
}
}, [])
const handleAtUriClick = (uri) => {
setModalUri(uri)
}
const handleCloseModal = () => {
setModalUri(null)
}
return (
<>
{children}
<AtUriModal
uri={modalUri}
onClose={handleCloseModal}
onAtUriClick={handleAtUriClick}
/>
</>
)
}
// Utility function to wrap at-uri text with clickable spans
export const wrapAtUris = (text) => {
const atUriRegex = /at:\/\/[^\s]+/g
return text.replace(atUriRegex, (match) => {
return `<span data-at-uri="${match}" style="color: blue; cursor: pointer; text-decoration: underline;">${match}</span>`
})
}

View File

@@ -0,0 +1,130 @@
/*
* Based on frontpage/atproto-browser
* Copyright (c) 2025 The Frontpage Authors
* MIT License
*/
import React from 'react'
import { isDid } from '@atproto/did'
import { parseAtUri, isAtUri } from '../lib/atproto.js'
const JSONString = ({ data, onAtUriClick }) => {
const handleClick = (uri) => {
if (onAtUriClick) {
onAtUriClick(uri)
}
}
return (
<pre style={{ color: 'darkgreen', margin: 0, display: 'inline' }}>
{isAtUri(data) ? (
<>
&quot;
<span
onClick={() => handleClick(data)}
style={{
color: 'blue',
cursor: 'pointer',
textDecoration: 'underline'
}}
>
{data}
</span>
&quot;
</>
) : isDid(data) ? (
<>
&quot;
<span
onClick={() => handleClick(`at://${data}`)}
style={{
color: 'blue',
cursor: 'pointer',
textDecoration: 'underline'
}}
>
{data}
</span>
&quot;
</>
) : URL.canParse(data) ? (
<>
&quot;
<a href={data} rel="noopener noreferrer ugc" target="_blank">
{data}
</a>
&quot;
</>
) : (
`"${data}"`
)}
</pre>
)
}
const JSONValue = ({ data, onAtUriClick }) => {
if (data === null) {
return <pre style={{ color: 'gray', margin: 0, display: 'inline' }}>null</pre>
}
if (typeof data === 'string') {
return <JSONString data={data} onAtUriClick={onAtUriClick} />
}
if (typeof data === 'number') {
return <pre style={{ color: 'darkorange', margin: 0, display: 'inline' }}>{data}</pre>
}
if (typeof data === 'boolean') {
return <pre style={{ color: 'darkred', margin: 0, display: 'inline' }}>{data.toString()}</pre>
}
if (Array.isArray(data)) {
return (
<div style={{ paddingLeft: '20px' }}>
[
{data.map((item, index) => (
<div key={index} style={{ paddingLeft: '20px' }}>
<JSONValue data={item} onAtUriClick={onAtUriClick} />
{index < data.length - 1 && ','}
</div>
))}
]
</div>
)
}
if (typeof data === 'object') {
return (
<div style={{ paddingLeft: '20px' }}>
{'{'}
{Object.entries(data).map(([key, value], index, entries) => (
<div key={key} style={{ paddingLeft: '20px' }}>
<span style={{ color: 'darkblue' }}>"{key}"</span>: <JSONValue data={value} onAtUriClick={onAtUriClick} />
{index < entries.length - 1 && ','}
</div>
))}
{'}'}
</div>
)
}
return <pre style={{ margin: 0, display: 'inline' }}>{String(data)}</pre>
}
export default function AtUriJson({ data, onAtUriClick }) {
return (
<div style={{
fontFamily: 'monospace',
fontSize: '14px',
padding: '10px',
backgroundColor: '#f5f5f5',
border: '1px solid #ddd',
borderRadius: '4px',
overflow: 'auto',
maxHeight: '400px'
}}>
<JSONValue data={data} onAtUriClick={onAtUriClick} />
</div>
)
}

View File

@@ -0,0 +1,80 @@
/*
* AT URI Modal Component
* Copyright (c) 2025 ai.log
* MIT License
*/
import React, { useEffect } from 'react'
import AtUriViewer from './AtUriViewer.jsx'
export function AtUriModal({ uri, onClose, onAtUriClick }) {
useEffect(() => {
const handleEscape = (e) => {
if (e.key === 'Escape') {
onClose()
}
}
const handleClickOutside = (e) => {
if (e.target.classList.contains('at-uri-modal-overlay')) {
onClose()
}
}
document.addEventListener('keydown', handleEscape)
document.addEventListener('click', handleClickOutside)
return () => {
document.removeEventListener('keydown', handleEscape)
document.removeEventListener('click', handleClickOutside)
}
}, [onClose])
if (!uri) return null
return (
<div className="at-uri-modal-overlay" style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000
}}>
<div style={{
backgroundColor: 'white',
borderRadius: '8px',
maxWidth: '800px',
maxHeight: '600px',
width: '90%',
height: '80%',
overflow: 'auto',
position: 'relative',
boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)'
}}>
<button
onClick={onClose}
style={{
position: 'absolute',
top: '10px',
right: '10px',
background: 'none',
border: 'none',
fontSize: '20px',
cursor: 'pointer',
zIndex: 1001,
padding: '5px 10px'
}}
>
×
</button>
<AtUriViewer uri={uri} onAtUriClick={onAtUriClick} />
</div>
</div>
)
}

View File

@@ -0,0 +1,103 @@
/*
* Based on frontpage/atproto-browser
* Copyright (c) 2025 The Frontpage Authors
* MIT License
*/
import React, { useState, useEffect } from 'react'
import { parseAtUri, getRecord } from '../lib/atproto.js'
import AtUriJson from './AtUriJson.jsx'
export default function AtUriViewer({ uri, onAtUriClick }) {
const [record, setRecord] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
const loadRecord = async () => {
if (!uri) return
setLoading(true)
setError(null)
try {
const atUri = parseAtUri(uri)
if (!atUri) {
throw new Error('Invalid AT URI')
}
const result = await getRecord(atUri.hostname, atUri.collection, atUri.rkey)
if (!result.success) {
throw new Error(result.error)
}
setRecord(result.data)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
loadRecord()
}, [uri])
if (loading) {
return (
<div style={{ padding: '20px', textAlign: 'center' }}>
<div>Loading...</div>
</div>
)
}
if (error) {
return (
<div style={{ padding: '20px', color: 'red' }}>
<div><strong>Error:</strong> {error}</div>
<div style={{ marginTop: '10px', fontSize: '12px' }}>
<strong>URI:</strong> {uri}
</div>
<div style={{ marginTop: '10px', fontSize: '12px', color: '#666' }}>
デバッグ情報: このAT URIは有効ではないかレコードが存在しません
</div>
</div>
)
}
if (!record) {
return (
<div style={{ padding: '20px' }}>
<div>No record found</div>
</div>
)
}
const atUri = parseAtUri(uri)
return (
<div style={{ padding: '20px' }}>
<div style={{ marginBottom: '20px' }}>
<h3 style={{ margin: '0 0 10px 0', fontSize: '18px' }}>AT URI Record</h3>
<div style={{
fontSize: '14px',
color: '#666',
fontFamily: 'monospace',
wordBreak: 'break-all'
}}>
{uri}
</div>
<div style={{ fontSize: '12px', color: '#999', marginTop: '5px' }}>
DID: {atUri.hostname} | Collection: {atUri.collection} | RKey: {atUri.rkey}
</div>
</div>
<div>
<h4 style={{ margin: '0 0 10px 0', fontSize: '16px' }}>Record Data</h4>
<AtUriJson data={record} onAtUriClick={onAtUriClick} />
</div>
</div>
)
}

33
pds/src/config.js Normal file
View File

@@ -0,0 +1,33 @@
/*
* AT Protocol Configuration for syu.is environment
*/
export const AT_PROTOCOL_CONFIG = {
// Primary PDS environment (syu.is)
primary: {
pds: 'https://syu.is',
plc: 'https://plc.syu.is',
bsky: 'https://bsky.syu.is',
web: 'https://web.syu.is'
},
// Fallback PDS environment (bsky.social)
fallback: {
pds: 'https://bsky.social',
plc: 'https://plc.directory',
bsky: 'https://public.api.bsky.app',
web: 'https://bsky.app'
}
}
export const getPDSConfig = (pds) => {
// Map PDS URL to appropriate config
if (pds.includes('syu.is')) {
return AT_PROTOCOL_CONFIG.primary
} else if (pds.includes('bsky.social')) {
return AT_PROTOCOL_CONFIG.fallback
}
// Default to primary for unknown PDS
return AT_PROTOCOL_CONFIG.primary
}

9
pds/src/index.js Normal file
View File

@@ -0,0 +1,9 @@
/*
* Based on frontpage/atproto-browser
* Copyright (c) 2025 The Frontpage Authors
* MIT License
*/
export { AtUriBrowser } from './components/AtUriBrowser.jsx'
export { AtUriModal } from './components/AtUriModal.jsx'
export { default as AtUriViewer } from './components/AtUriViewer.jsx'

155
pds/src/lib/atproto.js Normal file
View File

@@ -0,0 +1,155 @@
/*
* Based on frontpage/atproto-browser
* Copyright (c) 2025 The Frontpage Authors
* MIT License
*/
import { AtpBaseClient } from '@atproto/api'
import { AtUri } from '@atproto/syntax'
import { isDid } from '@atproto/did'
import { AT_PROTOCOL_CONFIG } from '../config.js'
// Identity resolution cache
const identityCache = new Map()
// Create AT Protocol client
export const createAtpClient = (pds) => {
return new AtpBaseClient({
service: pds.startsWith('http') ? pds : `https://${pds}`
})
}
// Resolve identity (DID/Handle)
export const resolveIdentity = async (identifier) => {
if (identityCache.has(identifier)) {
return identityCache.get(identifier)
}
try {
let did = identifier
// If it's a handle, resolve to DID
if (!isDid(identifier)) {
// Try syu.is first, then fallback to bsky.social
let resolved = false
try {
const client = createAtpClient(AT_PROTOCOL_CONFIG.primary.pds)
const response = await client.com.atproto.repo.describeRepo({ repo: identifier })
did = response.data.did
resolved = true
} catch (error) {
}
if (!resolved) {
try {
const client = createAtpClient(AT_PROTOCOL_CONFIG.fallback.pds)
const response = await client.com.atproto.repo.describeRepo({ repo: identifier })
did = response.data.did
} catch (error) {
throw new Error(`Failed to resolve handle: ${identifier}`)
}
}
}
// Get DID document to find PDS
// Try plc.syu.is first, then fallback to plc.directory
let didDoc = null
let plcResponse = null
try {
plcResponse = await fetch(`${AT_PROTOCOL_CONFIG.primary.plc}/${did}`)
if (plcResponse.ok) {
didDoc = await plcResponse.json()
}
} catch (error) {
}
// If plc.syu.is fails, try plc.directory
if (!didDoc) {
try {
plcResponse = await fetch(`${AT_PROTOCOL_CONFIG.fallback.plc}/${did}`)
if (plcResponse.ok) {
didDoc = await plcResponse.json()
}
} catch (error) {
}
}
if (!didDoc) {
throw new Error(`Failed to resolve DID document from any PLC server`)
}
// Find PDS service endpoint
const pdsService = didDoc.service?.find(service =>
service.type === 'AtprotoPersonalDataServer' ||
service.id === '#atproto_pds'
)
if (!pdsService) {
throw new Error('No PDS service found in DID document')
}
const result = {
success: true,
didDocument: didDoc,
pdsUrl: pdsService.serviceEndpoint
}
identityCache.set(identifier, result)
return result
} catch (error) {
const result = {
success: false,
error: error.message
}
identityCache.set(identifier, result)
return result
}
}
// Get record from AT Protocol
export const getRecord = async (did, collection, rkey) => {
try {
const identityResult = await resolveIdentity(did)
if (!identityResult.success) {
return { success: false, error: identityResult.error }
}
const pdsUrl = identityResult.pdsUrl
const client = createAtpClient(pdsUrl)
const response = await client.com.atproto.repo.getRecord({
repo: did,
collection,
rkey
})
return {
success: true,
data: response.data,
pdsUrl
}
} catch (error) {
return {
success: false,
error: error.message
}
}
}
// Parse AT URI
export const parseAtUri = (uri) => {
try {
return new AtUri(uri)
} catch (error) {
return null
}
}
// Check if string is AT URI
export const isAtUri = (str) => {
return str.startsWith('at://') && str.split(' ').length === 1
}

9
pds/src/main.jsx Normal file
View File

@@ -0,0 +1,9 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

10
pds/vite.config.js Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
base: '/pds/',
define: {
'process.env.NODE_ENV': JSON.stringify('production')
}
})

View File

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

View File

@@ -2,9 +2,10 @@
function _env() { function _env() {
d=${0:a:h} d=${0:a:h}
ailog=$d/target/debug/ailog ailog=$d/target/release/ailog
oauth=$d/oauth oauth=$d/oauth
myblog=$d/my-blog myblog=$d/my-blog
pds=$d/pds
port=4173 port=4173
#source $oauth/.env.production #source $oauth/.env.production
case $OSTYPE in case $OSTYPE in
@@ -22,7 +23,7 @@ function _deploy_ailog() {
function _server() { function _server() {
lsof -ti:$port | xargs kill -9 2>/dev/null || true lsof -ti:$port | xargs kill -9 2>/dev/null || true
cd $d/my-blog cd $d/my-blog
cargo build cargo build --release
cp -rf $ailog $CARGO_HOME/bin/ cp -rf $ailog $CARGO_HOME/bin/
$ailog build $ailog build
$ailog serve --port $port $ailog serve --port $port
@@ -43,8 +44,24 @@ function _oauth_build() {
#npm run preview #npm run preview
} }
function _pds_build() {
cd $pds
nvm use 21
npm i
npm run build
rm -rf $myblog/static/pds
cp -rf dist $myblog/static/pds
}
function _pds_server() {
cd $pds
nvm use 21
npm run preview
}
function _server_comment() { function _server_comment() {
cargo build cargo build --release
cp -rf $ailog $CARGO_HOME/bin/ cp -rf $ailog $CARGO_HOME/bin/
AILOG_DEBUG_ALL=1 $ailog stream start my-blog AILOG_DEBUG_ALL=1 $ailog stream start my-blog
} }
@@ -64,6 +81,12 @@ case "${1:-serve}" in
oauth|o) oauth|o)
_oauth_build _oauth_build
;; ;;
pds|p)
_pds_build
;;
pds-server|ps)
_pds_server
;;
n) n)
oauth=$d/oauth_old oauth=$d/oauth_old
_oauth_build _oauth_build

View File

@@ -1,3 +1,4 @@
pub mod oauth; pub mod oauth;
pub mod client; pub mod client;
pub mod comment_sync; pub mod comment_sync;
pub mod profile;

215
src/atproto/profile.rs Normal file
View File

@@ -0,0 +1,215 @@
use anyhow::Result;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetworkConfig {
pub pds_api: String,
pub plc_api: String,
pub bsky_api: String,
pub web_url: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Profile {
pub did: String,
pub handle: String,
pub display_name: Option<String>,
pub avatar: Option<String>,
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RepoDescription {
pub did: String,
pub handle: String,
#[serde(rename = "didDoc")]
pub did_doc: DidDoc,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DidDoc {
pub service: Vec<Service>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Service {
#[serde(rename = "serviceEndpoint")]
pub service_endpoint: String,
}
pub struct ProfileFetcher {
client: reqwest::Client,
}
impl ProfileFetcher {
pub fn new() -> Self {
Self {
client: reqwest::Client::new(),
}
}
/// Get network configuration based on PDS
pub fn get_network_config(pds: &str) -> NetworkConfig {
match pds {
"bsky.social" | "bsky.app" => NetworkConfig {
pds_api: format!("https://{}", pds),
plc_api: "https://plc.directory".to_string(),
bsky_api: "https://public.api.bsky.app".to_string(),
web_url: "https://bsky.app".to_string(),
},
"syu.is" => NetworkConfig {
pds_api: "https://syu.is".to_string(),
plc_api: "https://plc.syu.is".to_string(),
bsky_api: "https://bsky.syu.is".to_string(),
web_url: "https://web.syu.is".to_string(),
},
_ => {
// Default to Bluesky network for unknown PDS
NetworkConfig {
pds_api: format!("https://{}", pds),
plc_api: "https://plc.directory".to_string(),
bsky_api: "https://public.api.bsky.app".to_string(),
web_url: "https://bsky.app".to_string(),
}
}
}
}
/// Fetch DID and PDS from handle
pub async fn describe_repo(&self, handle: &str, pds: &str) -> Result<RepoDescription> {
let network_config = Self::get_network_config(pds);
let url = format!("{}/xrpc/com.atproto.repo.describeRepo", network_config.pds_api);
let response = self.client
.get(&url)
.query(&[("repo", handle)])
.send()
.await?;
if !response.status().is_success() {
return Err(anyhow::anyhow!("Failed to describe repo: {}", response.status()));
}
let repo_desc: RepoDescription = response.json().await?;
Ok(repo_desc)
}
/// Get user's PDS from their DID document
pub fn extract_pds_from_repo_desc(repo_desc: &RepoDescription) -> Option<String> {
repo_desc.did_doc.service.first().map(|service| {
// Extract hostname from service endpoint
let endpoint = &service.service_endpoint;
if let Some(url) = endpoint.strip_prefix("https://") {
if let Some(host) = url.split('/').next() {
return host.to_string();
}
}
endpoint.clone()
})
}
/// Fetch profile from bsky API
pub async fn get_profile(&self, did: &str, pds: &str) -> Result<Profile> {
let network_config = Self::get_network_config(pds);
let url = format!("{}/xrpc/app.bsky.actor.getProfile", network_config.bsky_api);
let response = self.client
.get(&url)
.query(&[("actor", did)])
.send()
.await?;
if !response.status().is_success() {
return Err(anyhow::anyhow!("Failed to get profile: {}", response.status()));
}
let profile_data: Value = response.json().await?;
let profile = Profile {
did: did.to_string(),
handle: profile_data["handle"].as_str().unwrap_or("").to_string(),
display_name: profile_data["displayName"].as_str().map(|s| s.to_string()),
avatar: profile_data["avatar"].as_str().map(|s| s.to_string()),
description: profile_data["description"].as_str().map(|s| s.to_string()),
};
Ok(profile)
}
/// Fetch complete profile information from handle and PDS
pub async fn fetch_profile_from_handle(&self, handle: &str, pds: &str) -> Result<Profile> {
println!("🔍 Fetching profile for handle: {} from PDS: {}", handle, pds);
// First, get DID from handle
let repo_desc = self.describe_repo(handle, pds).await?;
let did = repo_desc.did.clone();
// Determine the actual PDS from the DID document
let actual_pds = Self::extract_pds_from_repo_desc(&repo_desc)
.unwrap_or_else(|| pds.to_string());
println!("📍 Found DID: {} with PDS: {}", did, actual_pds);
// Get profile from the actual PDS
let profile = self.get_profile(&did, &actual_pds).await?;
println!("✅ Profile fetched: {} ({})", profile.display_name.as_deref().unwrap_or(&profile.handle), profile.did);
Ok(profile)
}
/// Generate profile URL for a given DID and PDS
#[allow(dead_code)]
pub fn generate_profile_url(did: &str, pds: &str) -> String {
let network_config = Self::get_network_config(pds);
match pds {
"syu.is" => format!("https://syu.is/profile/{}", did),
_ => format!("{}/profile/{}", network_config.web_url, did),
}
}
/// Convert Profile to JSON format used by the application
#[allow(dead_code)]
pub fn profile_to_json(&self, profile: &Profile, _pds: &str) -> Value {
serde_json::json!({
"did": profile.did,
"handle": profile.handle,
"displayName": profile.display_name.as_deref().unwrap_or(&profile.handle),
"avatar": profile.avatar.as_deref().unwrap_or(&format!("https://bsky.syu.is/img/avatar/plain/{}/default@jpeg", profile.did))
})
}
}
impl Default for ProfileFetcher {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_network_config() {
let config = ProfileFetcher::get_network_config("syu.is");
assert_eq!(config.pds_api, "https://syu.is");
assert_eq!(config.bsky_api, "https://bsky.syu.is");
let config = ProfileFetcher::get_network_config("bsky.social");
assert_eq!(config.pds_api, "https://bsky.social");
assert_eq!(config.bsky_api, "https://public.api.bsky.app");
}
#[test]
fn test_profile_url_generation() {
let did = "did:plc:test123";
let url = ProfileFetcher::generate_profile_url(did, "syu.is");
assert_eq!(url, "https://syu.is/profile/did:plc:test123");
let url = ProfileFetcher::generate_profile_url(did, "bsky.social");
assert_eq!(url, "https://bsky.app/profile/did:plc:test123");
}
}

View File

@@ -245,7 +245,7 @@ pub async fn init_with_pds(pds_override: Option<String>) -> Result<()> {
} }
} else { } else {
// Auto-detect from handle suffix // Auto-detect from handle suffix
if handle.ends_with(".syu.is") { if handle.ends_with(".syu.is") || handle.ends_with(".syui.ai") {
"https://syu.is".to_string() "https://syu.is".to_string()
} else { } else {
"https://bsky.social".to_string() "https://bsky.social".to_string()
@@ -537,19 +537,30 @@ fn migrate_config_if_needed(config_path: &std::path::Path, config_json: &str) ->
// Load config with automatic token refresh // Load config with automatic token refresh
pub async fn load_config_with_refresh() -> Result<AuthConfig> { pub async fn load_config_with_refresh() -> Result<AuthConfig> {
let mut config = load_config()?; let mut config = load_config()?;
let old_access_jwt = config.admin.access_jwt.clone();
// Test if current access token is still valid // Always try to refresh token to avoid any expiration issues
if let Err(_) = test_api_access_with_auth(&config).await { println!("{}", "🔄 Refreshing access token...".yellow());
println!("{}", "🔄 Access token expired, refreshing...".yellow()); println!("📍 Current access JWT: {}...", &old_access_jwt[..30.min(old_access_jwt.len())]);
// Try to refresh the token // Try to refresh the token
match refresh_access_token(&mut config).await { match refresh_access_token(&mut config).await {
Ok(_) => { Ok(_) => {
if config.admin.access_jwt != old_access_jwt {
println!("{}", "✅ Token refreshed with new JWT".green());
println!("📍 New access JWT: {}...", &config.admin.access_jwt[..30.min(config.admin.access_jwt.len())]);
save_config(&config)?; save_config(&config)?;
println!("{}", "✅ Token refreshed successfully".green()); println!("{}", "💾 Config saved to disk".green());
} else {
println!("{}", " Token refresh returned same JWT (still valid)".cyan());
} }
Err(e) => { }
return Err(anyhow::anyhow!("Failed to refresh token: {}. Please run 'ailog auth init' again.", e)); Err(e) => {
// If refresh fails, test if current token is still valid
if let Ok(_) = test_api_access_with_auth(&config).await {
println!("{}", " Refresh failed but current token is still valid".cyan());
} else {
return Err(anyhow::anyhow!("Token expired and refresh failed: {}. Please run 'ailog auth init' again.", e));
} }
} }
} }
@@ -584,6 +595,9 @@ async fn refresh_access_token(config: &mut AuthConfig) -> Result<()> {
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let url = format!("{}/xrpc/com.atproto.server.refreshSession", config.admin.pds); let url = format!("{}/xrpc/com.atproto.server.refreshSession", config.admin.pds);
println!("🔑 Refreshing token at: {}", url);
println!("🔑 Using refresh JWT: {}...", &config.admin.refresh_jwt[..20.min(config.admin.refresh_jwt.len())]);
let response = client let response = client
.post(&url) .post(&url)
.header("Authorization", format!("Bearer {}", config.admin.refresh_jwt)) .header("Authorization", format!("Bearer {}", config.admin.refresh_jwt))
@@ -601,10 +615,16 @@ async fn refresh_access_token(config: &mut AuthConfig) -> Result<()> {
// Update tokens // Update tokens
if let Some(access_jwt) = refresh_response["accessJwt"].as_str() { if let Some(access_jwt) = refresh_response["accessJwt"].as_str() {
config.admin.access_jwt = access_jwt.to_string(); config.admin.access_jwt = access_jwt.to_string();
println!("✅ New access JWT: {}...", &access_jwt[..20.min(access_jwt.len())]);
} else {
println!("⚠️ No accessJwt in refresh response");
} }
if let Some(refresh_jwt) = refresh_response["refreshJwt"].as_str() { if let Some(refresh_jwt) = refresh_response["refreshJwt"].as_str() {
config.admin.refresh_jwt = refresh_jwt.to_string(); config.admin.refresh_jwt = refresh_jwt.to_string();
println!("✅ New refresh JWT: {}...", &refresh_jwt[..20.min(refresh_jwt.len())]);
} else {
println!("⚠️ No refreshJwt in refresh response");
} }
Ok(()) Ok(())
@@ -612,8 +632,43 @@ async fn refresh_access_token(config: &mut AuthConfig) -> Result<()> {
fn save_config(config: &AuthConfig) -> Result<()> { fn save_config(config: &AuthConfig) -> Result<()> {
let config_path = get_config_path()?; let config_path = get_config_path()?;
println!("💾 Saving config to: {}", config_path.display());
// Read old config to compare
let old_config = if config_path.exists() {
fs::read_to_string(&config_path).ok()
} else {
None
};
let config_json = serde_json::to_string_pretty(config)?; let config_json = serde_json::to_string_pretty(config)?;
fs::write(&config_path, config_json)?; fs::write(&config_path, &config_json)?;
// Verify the write was successful
let saved_content = fs::read_to_string(&config_path)?;
if saved_content == config_json {
println!("✅ Config successfully saved to {}", config_path.display());
// Compare tokens if old config exists
if let Some(old) = old_config {
if let (Ok(old_json), Ok(new_json)) = (
serde_json::from_str::<AuthConfig>(&old),
serde_json::from_str::<AuthConfig>(&config_json)
) {
if old_json.admin.access_jwt != new_json.admin.access_jwt {
println!("📝 Access JWT was updated in file");
println!(" Old: {}...", &old_json.admin.access_jwt[..30.min(old_json.admin.access_jwt.len())]);
println!(" New: {}...", &new_json.admin.access_jwt[..30.min(new_json.admin.access_jwt.len())]);
}
if old_json.admin.refresh_jwt != new_json.admin.refresh_jwt {
println!("📝 Refresh JWT was updated in file");
}
}
}
} else {
println!("❌ Config save verification failed!");
}
Ok(()) Ok(())
} }

Some files were not shown because too many files have changed in this diff Show More