Compare commits
25 Commits
75f108e7b8
...
main
Author | SHA1 | Date | |
---|---|---|---|
5aeeba106a
|
|||
f1e76ab31f
|
|||
3c9ef78696
|
|||
ee2d21b0f3
|
|||
0667ac58fb
|
|||
d89855338b
|
|||
e19170cdff
|
|||
c3e22611f5
|
|||
2943c94ec1
|
|||
f27997b7e8
|
|||
447e4bded9
|
|||
03161a52ca
|
|||
fe9381a860
|
|||
f0cea89005
|
|||
b059fe1de0
|
|||
07b0b0f702
|
|||
ecd69557fe
|
|||
452a0fda6a
|
|||
a62dd82790
|
|||
3faec33bac
|
|||
33402f4a21
|
|||
3e65bc8210
|
|||
16d724ec25
|
|||
69182a1bf8
|
|||
0110773592
|
11
.github/workflows/cloudflare-pages.yml
vendored
11
.github/workflows/cloudflare-pages.yml
vendored
@@ -40,6 +40,17 @@ jobs:
|
||||
rm -rf my-blog/static/assets
|
||||
cp -rf ${{ env.OAUTH_DIR }}/dist/* my-blog/static/
|
||||
cp ${{ env.OAUTH_DIR }}/dist/index.html my-blog/templates/oauth-assets.html
|
||||
|
||||
- name: 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
|
||||
uses: actions/cache@v4
|
||||
|
2
.gitignore
vendored
2
.gitignore
vendored
@@ -10,6 +10,7 @@ dist
|
||||
node_modules
|
||||
package-lock.json
|
||||
my-blog/static/assets/comment-atproto-*
|
||||
my-blog/static/ai-assets/comment-atproto-*
|
||||
bin/ailog
|
||||
docs
|
||||
my-blog/static/index.html
|
||||
@@ -23,3 +24,4 @@ my-blog/static/oauth/assets/comment-atproto*
|
||||
*.lock
|
||||
my-blog/config.toml
|
||||
.claude/settings.local.json
|
||||
my-blog/static/pds
|
||||
|
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "ailog"
|
||||
version = "0.2.7"
|
||||
version = "0.3.1"
|
||||
edition = "2021"
|
||||
authors = ["syui"]
|
||||
description = "A static blog generator with AI features"
|
||||
@@ -56,6 +56,8 @@ tokio-tungstenite = { version = "0.21", features = ["rustls-tls-webpki-roots", "
|
||||
futures-util = "0.3"
|
||||
tungstenite = { version = "0.21", features = ["rustls-tls-webpki-roots"], default-features = false }
|
||||
rpassword = "7.3"
|
||||
rustyline = "14.0"
|
||||
dirs = "5.0"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.14"
|
||||
|
Binary file not shown.
@@ -21,6 +21,72 @@ host = "localhost:11434"
|
||||
system_prompt = "あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。"
|
||||
handle = "ai.syui.ai"
|
||||
|
||||
[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]
|
||||
json = "client-metadata.json"
|
||||
redirect = "oauth/callback"
|
||||
@@ -28,3 +94,30 @@ admin = "ai.syui.ai"
|
||||
collection = "ai.syui.log"
|
||||
pds = "syu.is"
|
||||
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"
|
||||
]
|
||||
|
@@ -56,7 +56,7 @@ $ curl -sL "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?
|
||||
did:plc:6qyecktefllvenje24fcxnie
|
||||
```
|
||||
|
||||
[msg type="warning" content="現在はbsky.teamのplc, pdsにもhandle-changeが反映されています。"]
|
||||
[msg type="warning" content="現在はbsky.teamのpdsにhandle-changeが反映されています。"]
|
||||
|
||||
oauthは、そのままではbsky.teamのpds, plcを使って名前解決を行います。この場合、まず、それらのserverにdidが登録されている必要があります。
|
||||
|
||||
|
@@ -68,7 +68,6 @@ file_permissions=(
|
||||
後に`ai/os`と統合するかもしれません。
|
||||
|
||||
1. [https://git.syui.ai/ai/os](https://git.syui.ai/ai/os)
|
||||
2. [https://git.syui.ai/syui/aios](https://git.syui.ai/syui/aios)
|
||||
|
||||
```sh
|
||||
#!/bin/zsh
|
||||
|
@@ -103,9 +103,7 @@ draft: false
|
||||
|
||||
アイは、最初に生まれたキャラクターとして、アイ属性を扱います。これらの設定は`ai system`の領域です。アイは自分のことをアイと呼びます。
|
||||
|
||||
> アイね、この世界と一緒だから。この世界に同じものは一つもないよ。
|
||||
|
||||
これはアイのセリフ。存在の世界の同一性と唯一性のことを言っているのです。
|
||||
> アイは、この世界と一緒だからね。同じものは一つもないよ。
|
||||
|
||||
# どこまで実装できた
|
||||
|
||||
|
10
my-blog/content/posts/2025-07-16-6bf4b020.md
Normal file
10
my-blog/content/posts/2025-07-16-6bf4b020.md
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
title: "ゲームとAI制御"
|
||||
slug: "6bf4b020"
|
||||
date: "2025-07-16"
|
||||
tags: ["ai", "conversation"]
|
||||
draft: false
|
||||
extra:
|
||||
type: "ai"
|
||||
---
|
||||
|
40
my-blog/content/posts/2025-07-16-ailog.md
Normal file
40
my-blog/content/posts/2025-07-16-ailog.md
Normal 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)
|
64
my-blog/content/posts/2025-07-30-game.md
Normal file
64
my-blog/content/posts/2025-07-30-game.md
Normal 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に指定します。
|
||||
|
||||

|
||||
|
||||
次に、動きに合わせて首を上下させます。
|
||||
|
||||
<iframe src="https://blueprintue.com/render/piiw14oz" scrolling="no" allowfullscreen style="width:100%;height:400px"></iframe>
|
14
my-blog/layouts/shortcodes/msg.html
Normal file
14
my-blog/layouts/shortcodes/msg.html
Normal 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>
|
345
my-blog/static/css/pds.css
Normal file
345
my-blog/static/css/pds.css
Normal 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;
|
||||
}
|
||||
}
|
||||
|
@@ -55,6 +55,73 @@ a.view-markdown:any-link {
|
||||
text-decoration: none !important;
|
||||
}
|
||||
|
||||
/* AI Conversation Styles */
|
||||
|
||||
.ai-conversation-display {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
/* Style adjustments for AI conversation in chat area */
|
||||
.ai-conversation-display .chat-message {
|
||||
margin-bottom: 16px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.ai-conversation-display .chat-message.ai-message {
|
||||
background: linear-gradient(135deg, #f8f9ff, #f0f4ff);
|
||||
border-left: 4px solid #667eea;
|
||||
}
|
||||
|
||||
.ai-conversation-display .chat-message.user-message {
|
||||
background: linear-gradient(135deg, #fff8f0, #fff4f0);
|
||||
border-left: 4px solid #ff6b35;
|
||||
}
|
||||
|
||||
.ai-conversation-display .message-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
padding: 12px 16px 0;
|
||||
}
|
||||
|
||||
.ai-conversation-display .message-content {
|
||||
padding: 0 16px 16px;
|
||||
line-height: 1.6;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.ai-conversation-display .avatar img {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.ai-conversation-display .user-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ai-conversation-display .display-name {
|
||||
font-weight: 600;
|
||||
color: #1a202c;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.ai-conversation-display .handle {
|
||||
font-size: 12px;
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
.ai-conversation-display .handle a {
|
||||
color: #667eea;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.ai-conversation-display .handle a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
.container {
|
||||
min-height: 100vh;
|
||||
@@ -72,7 +139,7 @@ a.view-markdown:any-link {
|
||||
grid-area: header;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #d1d9e0;
|
||||
padding: 16px 24px;
|
||||
padding: 17px 24px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
@@ -656,7 +723,7 @@ article.article-content {
|
||||
.footer-social a {
|
||||
color: var(--dark-gray) !important;
|
||||
text-decoration: none !important;
|
||||
font-size: 20px;
|
||||
font-size: 25px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
@@ -777,7 +844,6 @@ article.article-content {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* OAuth Comment System - Hide on homepage by default, show on post pages */
|
||||
@@ -885,9 +951,11 @@ article.article-content {
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
/*
|
||||
.form-group {
|
||||
margin-bottom: 15px !important;
|
||||
}
|
||||
*/
|
||||
|
||||
.form-input, .form-textarea {
|
||||
width: 100% !important;
|
||||
@@ -1449,3 +1517,340 @@ article.article-content {
|
||||
}
|
||||
}
|
||||
|
||||
/* AI Conversation Display Styles */
|
||||
.ai-conversation-notice {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ai-conversation-display {
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.ai-conversation-loading {
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.ai-conversation-list {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.ai-conversation-pair {
|
||||
margin-bottom: 32px;
|
||||
border: 1px solid #e1e4e8;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.ai-question {
|
||||
background: #f8f9fa;
|
||||
border-bottom: 1px solid #e1e4e8;
|
||||
}
|
||||
|
||||
.ai-answer {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.ai-question .message-header,
|
||||
.ai-answer .message-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px 20px 8px 20px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ai-question .avatar,
|
||||
.ai-answer .avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid #e1e4e8;
|
||||
}
|
||||
|
||||
.ai-question .user-info,
|
||||
.ai-answer .user-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ai-question .display-name,
|
||||
.ai-answer .display-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #1f2328;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.ai-question .handle,
|
||||
.ai-answer .handle {
|
||||
font-size: 12px;
|
||||
color: #656d76;
|
||||
}
|
||||
|
||||
.ai-question .message-content,
|
||||
.ai-answer .message-content {
|
||||
padding: 8px 20px 20px 20px;
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
color: #1f2328;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.ai-question .message-content {
|
||||
background: #f8f9fa;
|
||||
border-left: 4px solid #0969da;
|
||||
margin-left: 16px;
|
||||
margin-right: 16px;
|
||||
border-radius: 0 6px 6px 0;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.ai-answer .message-content {
|
||||
background: #fff;
|
||||
border-left: 4px solid #7c3aed;
|
||||
margin-left: 16px;
|
||||
margin-right: 16px;
|
||||
border-radius: 0 6px 6px 0;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
/* Responsive styles for AI conversation */
|
||||
@media (max-width: 768px) {
|
||||
.ai-conversation-pair {
|
||||
margin-bottom: 24px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.ai-question .message-header,
|
||||
.ai-answer .message-header {
|
||||
padding: 12px 16px 6px 16px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.ai-question .avatar,
|
||||
.ai-answer .avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.ai-question .message-content,
|
||||
.ai-answer .message-content {
|
||||
padding: 6px 16px 16px 16px;
|
||||
font-size: 14px;
|
||||
margin-left: 12px;
|
||||
margin-right: 12px;
|
||||
padding-left: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.ai-conversation-pair {
|
||||
margin-bottom: 20px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.ai-question .message-header,
|
||||
.ai-answer .message-header {
|
||||
padding: 10px 12px 4px 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ai-question .avatar,
|
||||
.ai-answer .avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.ai-question .display-name,
|
||||
.ai-answer .display-name {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ai-question .handle,
|
||||
.ai-answer .handle {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.ai-question .message-content,
|
||||
.ai-answer .message-content {
|
||||
padding: 4px 12px 12px 12px;
|
||||
font-size: 13px;
|
||||
margin-left: 8px;
|
||||
margin-right: 8px;
|
||||
padding-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* AI Conversation Integration Styles */
|
||||
.chat-separator {
|
||||
margin: 24px 0;
|
||||
border-bottom: 1px solid var(--dark-white);
|
||||
}
|
||||
|
||||
.article-body .chat-message {
|
||||
margin-bottom: 24px;
|
||||
background: var(--background);
|
||||
border: 1px solid var(--dark-white);
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.article-body .chat-message:hover {
|
||||
background: var(--light-gray);
|
||||
border-color: var(--dark-gray);
|
||||
}
|
||||
|
||||
.article-body .chat-message.user-message {
|
||||
border-left: 4px solid var(--theme-color);
|
||||
}
|
||||
|
||||
.article-body .chat-message.ai-message {
|
||||
border-left: 4px solid var(--ai-color);
|
||||
background: #faf8ff;
|
||||
}
|
||||
|
||||
.article-body .message-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
|
||||
.article-body .message-header .avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--light-gray);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
border: 1px solid var(--dark-white);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.article-body .message-header .user-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.article-body .message-header .display-name {
|
||||
font-weight: 600;
|
||||
color: #1f2328;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.article-body .message-header .handle {
|
||||
color: var(--dark-gray);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.article-body .message-header .handle-link {
|
||||
color: var(--dark-gray);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.article-body .message-header .handle-link:hover {
|
||||
color: var(--theme-color);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.article-body .message-content {
|
||||
color: #1f2328;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* Ensure proper spacing for markdown content in chat messages */
|
||||
.article-body .message-content h1,
|
||||
.article-body .message-content h2,
|
||||
.article-body .message-content h3,
|
||||
.article-body .message-content h4,
|
||||
.article-body .message-content h5,
|
||||
.article-body .message-content h6 {
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.article-body .message-content p {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.article-body .message-content ul,
|
||||
.article-body .message-content ol {
|
||||
margin-bottom: 1rem;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.article-body .message-content li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.article-body .message-content blockquote {
|
||||
margin: 1rem 0;
|
||||
padding-left: 1rem;
|
||||
border-left: 3px solid var(--dark-white);
|
||||
color: var(--dark-gray);
|
||||
}
|
||||
|
||||
.article-body .message-content pre {
|
||||
background: #f6f8fa;
|
||||
border: 1px solid var(--dark-white);
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
overflow-x: auto;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.article-body .message-content code {
|
||||
background: #f6f8fa;
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.article-body .message-content table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.article-body .message-content table th,
|
||||
.article-body .message-content table td {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--dark-white);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.article-body .message-content table th {
|
||||
background: var(--light-gray);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
|
||||
button.ask-at-btn {
|
||||
margin: 10px;
|
||||
background: var(--theme-color);
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
button.ask-at-btn a {
|
||||
color: var(--ai-color);
|
||||
}
|
||||
|
||||
button#searchButton.pds-btn {
|
||||
background: var(--theme-color);
|
||||
}
|
||||
|
BIN
my-blog/static/img/ue_gasp_dragonik_shin_v0001.png
Normal file
BIN
my-blog/static/img/ue_gasp_dragonik_shin_v0001.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 723 KiB |
@@ -12,14 +12,12 @@ const OAUTH_COLLECTION = window.OAUTH_CONFIG?.collection || 'ai.syui.log';
|
||||
|
||||
// Listen for AI profile data from OAuth app
|
||||
window.addEventListener('aiProfileLoaded', function(event) {
|
||||
console.log('AI profile received from OAuth app:', event.detail);
|
||||
aiProfileData = event.detail;
|
||||
updateAskAIButton();
|
||||
});
|
||||
|
||||
// Check if AI profile data is already available
|
||||
if (window.aiProfileData) {
|
||||
console.log('AI profile already available:', window.aiProfileData);
|
||||
aiProfileData = window.aiProfileData;
|
||||
}
|
||||
|
||||
@@ -30,11 +28,9 @@ function toggleAskAI() {
|
||||
panel.style.display = isVisible ? 'none' : 'block';
|
||||
|
||||
if (!isVisible) {
|
||||
console.log('Ask AI panel opened');
|
||||
|
||||
// If AI profile data is already available, show introduction immediately
|
||||
if (aiProfileData) {
|
||||
console.log('AI profile data available - showing introduction immediately');
|
||||
// Quick check for authentication
|
||||
const userSections = document.querySelectorAll('.user-section');
|
||||
const isAuthenticated = userSections.length > 0;
|
||||
@@ -45,17 +41,13 @@ function toggleAskAI() {
|
||||
// For production fallback - if OAuth app fails to load, show profiles
|
||||
const isProd = window.location.hostname !== 'localhost' && !window.location.hostname.includes('preview');
|
||||
if (isProd) {
|
||||
console.log('Production environment detected - using fallback profile display');
|
||||
// Shorter timeout for production
|
||||
setTimeout(() => {
|
||||
const userSections = document.querySelectorAll('.user-section');
|
||||
console.log('Production check - user sections:', userSections.length);
|
||||
|
||||
if (userSections.length === 0) {
|
||||
console.log('No user sections found in production - showing profiles directly');
|
||||
handleAuthenticationStatus(false);
|
||||
} else {
|
||||
console.log('User sections found in production - showing authenticated UI');
|
||||
handleAuthenticationStatus(true);
|
||||
}
|
||||
}, 300);
|
||||
@@ -71,19 +63,14 @@ function checkAuthenticationStatus() {
|
||||
const maxChecks = 10;
|
||||
|
||||
const checkForAuth = () => {
|
||||
console.log(`Auth check attempt ${checkCount + 1}/${maxChecks}`);
|
||||
const userSections = document.querySelectorAll('.user-section');
|
||||
const authButtons = document.querySelectorAll('[data-auth-status]');
|
||||
const oauthContainers = document.querySelectorAll('#oauth-container');
|
||||
|
||||
console.log('User sections found:', userSections.length);
|
||||
console.log('Auth buttons found:', authButtons.length);
|
||||
console.log('OAuth containers found:', oauthContainers.length);
|
||||
|
||||
const isAuthenticated = userSections.length > 0;
|
||||
|
||||
if (isAuthenticated || checkCount >= maxChecks - 1) {
|
||||
console.log('Final auth status:', isAuthenticated);
|
||||
handleAuthenticationStatus(isAuthenticated);
|
||||
} else {
|
||||
checkCount++;
|
||||
@@ -95,14 +82,12 @@ function checkAuthenticationStatus() {
|
||||
}
|
||||
|
||||
function handleAuthenticationStatus(isAuthenticated) {
|
||||
console.log('Handling auth status:', isAuthenticated);
|
||||
|
||||
// Always hide loading first
|
||||
document.getElementById('authCheck').style.display = 'none';
|
||||
|
||||
if (isAuthenticated) {
|
||||
// User is authenticated - show Ask AI UI
|
||||
console.log('User authenticated - showing AI chat interface');
|
||||
document.getElementById('chatForm').style.display = 'block';
|
||||
document.getElementById('chatHistory').style.display = 'block';
|
||||
|
||||
@@ -127,7 +112,6 @@ function handleAuthenticationStatus(isAuthenticated) {
|
||||
}, 50);
|
||||
} else {
|
||||
// User not authenticated - show AI introduction directly if profile available
|
||||
console.log('User not authenticated - showing AI introduction');
|
||||
document.getElementById('chatForm').style.display = 'none';
|
||||
document.getElementById('chatHistory').style.display = 'block';
|
||||
|
||||
@@ -154,18 +138,15 @@ async function loadAndShowProfiles() {
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('Fetched records:', data.records);
|
||||
|
||||
// Filter only profile records and sort
|
||||
const profileRecords = (data.records || []).filter(record => record.value.type === 'profile');
|
||||
console.log('Profile records:', profileRecords);
|
||||
|
||||
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;
|
||||
});
|
||||
console.log('Sorted profiles:', profiles);
|
||||
|
||||
// Clear loading message
|
||||
chatHistory.innerHTML = '';
|
||||
@@ -201,7 +182,6 @@ async function loadAndShowProfiles() {
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading profiles:', error);
|
||||
chatHistory.innerHTML = '<div class="error-message">Failed to load profiles. Please try again later.</div>';
|
||||
}
|
||||
}
|
||||
@@ -230,7 +210,6 @@ function askQuestion() {
|
||||
}));
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to ask question:', error);
|
||||
showErrorMessage('Sorry, I encountered an error. Please try again.');
|
||||
} finally {
|
||||
askButton.disabled = false;
|
||||
@@ -402,7 +381,6 @@ function handleAIResponse(responseData) {
|
||||
|
||||
const aiProfile = responseData.aiProfile;
|
||||
if (!aiProfile || !aiProfile.handle || !aiProfile.displayName) {
|
||||
console.error('AI profile data is missing');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -444,7 +422,6 @@ function setupAskAIEventListeners() {
|
||||
// Listen for AI profile updates from OAuth app
|
||||
window.addEventListener('aiProfileLoaded', function(event) {
|
||||
aiProfileData = event.detail;
|
||||
console.log('AI profile loaded:', aiProfileData);
|
||||
updateAskAIButton();
|
||||
});
|
||||
|
||||
@@ -456,7 +433,6 @@ function setupAskAIEventListeners() {
|
||||
// Listen for OAuth callback completion from iframe
|
||||
window.addEventListener('message', function(event) {
|
||||
if (event.data.type === 'oauth_success') {
|
||||
console.log('Received OAuth success message:', event.data);
|
||||
|
||||
// Close any OAuth popups/iframes
|
||||
const oauthFrame = document.getElementById('oauth-frame');
|
||||
@@ -505,7 +481,6 @@ function setupAskAIEventListeners() {
|
||||
// Initialize Ask AI when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
setupAskAIEventListeners();
|
||||
console.log('Ask AI initialized successfully');
|
||||
|
||||
// Also listen for OAuth app load completion
|
||||
const observer = new MutationObserver(function(mutations) {
|
||||
@@ -522,7 +497,6 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
);
|
||||
|
||||
if (userSectionAdded || userSectionRemoved) {
|
||||
console.log('User section status changed');
|
||||
// Update Ask AI panel if it's visible
|
||||
const panel = document.getElementById('askAiPanel');
|
||||
if (panel && panel.style.display !== 'none') {
|
||||
|
370
my-blog/static/js/pds.js
Normal file
370
my-blog/static/js/pds.js
Normal 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';
|
||||
}
|
||||
}
|
@@ -84,11 +84,10 @@ class Theme {
|
||||
setupLogoAnimations() {
|
||||
// Pure CSS animations are handled by the svg-animation-package.css
|
||||
// This method is reserved for any future JavaScript-based enhancements
|
||||
console.log('Logo animations initialized (CSS-based)');
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize theme when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
new Theme();
|
||||
});
|
||||
});
|
||||
|
@@ -1,3 +1,3 @@
|
||||
<!-- OAuth Comment System - Load globally for session management -->
|
||||
<script type="module" crossorigin src="/assets/comment-atproto-D0RrISz4.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/comment-atproto-BUFiApUA.css">
|
||||
<script type="module" crossorigin src="/assets/comment-atproto-B2YEFA6R.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/comment-atproto-BHjafP79.css">
|
||||
|
61
my-blog/templates/at-browser-assets.html
Normal file
61
my-blog/templates/at-browser-assets.html
Normal 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>
|
@@ -12,6 +12,7 @@
|
||||
<!-- Stylesheets -->
|
||||
<link rel="stylesheet" href="/css/style.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/font-awesome/css/all.min.css">
|
||||
|
||||
@@ -48,7 +49,18 @@
|
||||
</svg>
|
||||
</a>
|
||||
</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">
|
||||
<span class="ai-icon icon-ai"></span>
|
||||
ai
|
||||
@@ -75,8 +87,11 @@
|
||||
<div id="chatHistory" class="chat-history" style="display: none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<main class="main-content">
|
||||
<!-- Pds Panel -->
|
||||
{% include "pds-header.html" %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
@@ -113,9 +128,25 @@
|
||||
};
|
||||
</script>
|
||||
<script src="/js/ask-ai.js"></script>
|
||||
<script src="/js/pds.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 "at-browser-assets.html" %}
|
||||
</body>
|
||||
</html>
|
||||
|
135
my-blog/templates/game.html
Normal file
135
my-blog/templates/game.html
Normal 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 %}
|
48
my-blog/templates/pds-header.html
Normal file
48
my-blog/templates/pds-header.html
Normal 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()">×</button>
|
||||
<div id="atUriContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
6
my-blog/templates/pds.html
Normal file
6
my-blog/templates/pds.html
Normal file
@@ -0,0 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}at-uri browser - {{ config.title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% endblock %}
|
@@ -13,6 +13,7 @@
|
||||
<span class="article-lang">{{ post.language }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if not post.extra.type or post.extra.type != "ai" %}
|
||||
<div class="article-actions">
|
||||
{% if post.markdown_url %}
|
||||
<a href="{{ post.markdown_url }}" class="action-btn markdown-btn" title="View Markdown">
|
||||
@@ -25,8 +26,10 @@
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
{% if not post.extra.type or post.extra.type != "ai" %}
|
||||
<nav class="toc">
|
||||
<h3>Contents</h3>
|
||||
<div id="toc-content">
|
||||
@@ -37,15 +40,21 @@
|
||||
<div class="article-body">
|
||||
{{ post.content | safe }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div id="comment-atproto"></div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
|
||||
<script>
|
||||
// Generate table of contents
|
||||
function generateTableOfContents() {
|
||||
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');
|
||||
|
||||
if (headings.length === 0) {
|
||||
|
@@ -16,4 +16,4 @@ VITE_AI_SYSTEM_PROMPT="あなたは6歳の女の子アイです。明るく元
|
||||
|
||||
# Production settings - Disable development features
|
||||
VITE_ENABLE_TEST_UI=false
|
||||
VITE_ENABLE_DEBUG=false
|
||||
VITE_ENABLE_DEBUG=true
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ailog-oauth",
|
||||
"version": "0.2.7",
|
||||
"version": "0.3.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -8,10 +8,13 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@atproto/api": "^0.15.12",
|
||||
"@atproto/oauth-client-browser": "^0.3.19",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"@atproto/api": "^0.15.12",
|
||||
"@atproto/oauth-client-browser": "^0.3.19"
|
||||
"react-markdown": "^9.0.1",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"remark-gfm": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.0",
|
||||
|
@@ -126,11 +126,11 @@ body {
|
||||
|
||||
/* Header */
|
||||
.oauth-app-header {
|
||||
background: var(--background);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
width: 100%;
|
||||
background: var(--background);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.oauth-header-content {
|
||||
@@ -139,7 +139,7 @@ body {
|
||||
/* align-items: center; */
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 25px 0;
|
||||
padding: 30px 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -287,7 +287,6 @@ body {
|
||||
.auth-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.auth-section.search-bar-layout {
|
||||
@@ -302,10 +301,10 @@ body {
|
||||
.auth-section.search-bar-layout .handle-input {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
padding: 10px 15px;
|
||||
font-size: 16px;
|
||||
padding: 9px 15px;
|
||||
font-size: 13px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px 0 0 8px;
|
||||
border-radius: 4px 0 0 4px;
|
||||
background: var(--background);
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
@@ -319,12 +318,13 @@ body {
|
||||
}
|
||||
|
||||
.auth-section.search-bar-layout .auth-button {
|
||||
border-radius: 0 6px 6px 0;
|
||||
border-radius: 0 4px 4px 0;
|
||||
border: 1px solid var(--primary);
|
||||
border-left: none;
|
||||
margin: 0;
|
||||
padding: 10px 15px;
|
||||
height: 40px;
|
||||
padding: 9px 15px;
|
||||
min-width: 50px;
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
/* Auth Button */
|
||||
@@ -332,15 +332,26 @@ body {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
padding: 9px 15px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
height: 40px;
|
||||
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 {
|
||||
@@ -422,10 +433,6 @@ body {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: 700;
|
||||
@@ -919,10 +926,6 @@ body {
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 15px !important;
|
||||
}
|
||||
|
||||
.form-input, .form-textarea {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
@@ -1274,12 +1277,6 @@ body {
|
||||
/* Chat Conversation Styles */
|
||||
.chat-conversation {
|
||||
margin-bottom: 32px;
|
||||
padding-bottom: 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.chat-conversation:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.chat-message.comment-style {
|
||||
@@ -1343,10 +1340,144 @@ body {
|
||||
.message-content {
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
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;
|
||||
}
|
||||
|
@@ -14,7 +14,7 @@ import OAuthCallback from './components/OAuthCallback.jsx'
|
||||
|
||||
export default function App() {
|
||||
const { user, agent, loading: authLoading, login, logout } = useAuth()
|
||||
const { adminData, langRecords, commentRecords, loading: dataLoading, error, 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 [userChatRecords, setUserChatRecords] = useState([])
|
||||
const [userChatLoading, setUserChatLoading] = useState(false)
|
||||
@@ -22,6 +22,19 @@ export default function App() {
|
||||
const [showAskAI, setShowAskAI] = 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
|
||||
const ENABLE_TEST_UI = import.meta.env.VITE_ENABLE_TEST_UI === 'true'
|
||||
const ENABLE_DEBUG = import.meta.env.VITE_ENABLE_DEBUG === 'true'
|
||||
@@ -105,6 +118,14 @@ export default function App() {
|
||||
}
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
// Clear OAuth completion flag once app is loaded
|
||||
@@ -354,6 +375,7 @@ Answer:`
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
{!isAiPost && (
|
||||
<header className="oauth-app-header">
|
||||
<div className="oauth-header-content">
|
||||
{user && (
|
||||
@@ -394,6 +416,7 @@ Answer:`
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
|
||||
<div className="main-content">
|
||||
<div className="content-area">
|
||||
@@ -429,7 +452,9 @@ Answer:`
|
||||
langRecords={langRecords}
|
||||
commentRecords={commentRecords}
|
||||
userComments={userComments}
|
||||
chatRecords={chatRecords}
|
||||
chatRecords={adminChatRecords}
|
||||
chatHasMore={chatHasMore}
|
||||
onLoadMoreChat={loadMoreChat}
|
||||
userChatRecords={userChatRecords}
|
||||
userChatLoading={userChatLoading}
|
||||
baseRecords={adminData.records}
|
||||
@@ -461,9 +486,6 @@ Answer:`
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bluesky-footer">
|
||||
<i className="fab fa-bluesky"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -83,9 +83,16 @@ export const atproto = {
|
||||
return await request(`${apiEndpoint}/xrpc/${ENDPOINTS.getProfile}?actor=${actor}`)
|
||||
},
|
||||
|
||||
async getRecords(pds, repo, collection, limit = 10) {
|
||||
const res = await request(`${pds}/xrpc/${ENDPOINTS.listRecords}?repo=${repo}&collection=${collection}&limit=${limit}`)
|
||||
return res.records || []
|
||||
async getRecords(pds, repo, collection, limit = 10, cursor = null) {
|
||||
let url = `${pds}/xrpc/${ENDPOINTS.listRecords}?repo=${repo}&collection=${collection}&limit=${limit}`
|
||||
if (cursor) {
|
||||
url += `&cursor=${cursor}`
|
||||
}
|
||||
const res = await request(url)
|
||||
return {
|
||||
records: res.records || [],
|
||||
cursor: res.cursor || null
|
||||
}
|
||||
},
|
||||
|
||||
async searchPlc(plc, did) {
|
||||
@@ -121,8 +128,10 @@ export const collections = {
|
||||
if (cached) return cached
|
||||
|
||||
const data = await atproto.getRecords(pds, repo, collection, limit)
|
||||
dataCache.set(cacheKey, data)
|
||||
return data
|
||||
// Extract records array for backward compatibility
|
||||
const records = data.records || data
|
||||
dataCache.set(cacheKey, records)
|
||||
return records
|
||||
},
|
||||
|
||||
async getLang(pds, repo, collection, limit = 10) {
|
||||
@@ -131,8 +140,10 @@ export const collections = {
|
||||
if (cached) return cached
|
||||
|
||||
const data = await atproto.getRecords(pds, repo, `${collection}.chat.lang`, limit)
|
||||
dataCache.set(cacheKey, data)
|
||||
return data
|
||||
// Extract records array for backward compatibility
|
||||
const records = data.records || data
|
||||
dataCache.set(cacheKey, records)
|
||||
return records
|
||||
},
|
||||
|
||||
async getComment(pds, repo, collection, limit = 10) {
|
||||
@@ -141,17 +152,29 @@ export const collections = {
|
||||
if (cached) return cached
|
||||
|
||||
const data = await atproto.getRecords(pds, repo, `${collection}.chat.comment`, limit)
|
||||
dataCache.set(cacheKey, data)
|
||||
return data
|
||||
// Extract records array for backward compatibility
|
||||
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 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)
|
||||
dataCache.set(cacheKey, data)
|
||||
// Cache only the records array for backward compatibility
|
||||
dataCache.set(cacheKey, data.records || data)
|
||||
return data
|
||||
},
|
||||
|
||||
@@ -161,8 +184,10 @@ export const collections = {
|
||||
if (cached) return cached
|
||||
|
||||
const data = await atproto.getRecords(pds, repo, `${collection}.user`, limit)
|
||||
dataCache.set(cacheKey, data)
|
||||
return data
|
||||
// Extract records array for backward compatibility
|
||||
const records = data.records || data
|
||||
dataCache.set(cacheKey, records)
|
||||
return records
|
||||
},
|
||||
|
||||
async getUserComments(pds, repo, collection, limit = 10) {
|
||||
@@ -171,8 +196,10 @@ export const collections = {
|
||||
if (cached) return cached
|
||||
|
||||
const data = await atproto.getRecords(pds, repo, collection, limit)
|
||||
dataCache.set(cacheKey, data)
|
||||
return data
|
||||
// Extract records array for backward compatibility
|
||||
const records = data.records || data
|
||||
dataCache.set(cacheKey, records)
|
||||
return records
|
||||
},
|
||||
|
||||
async getProfiles(pds, repo, collection, limit = 100) {
|
||||
@@ -181,8 +208,10 @@ export const collections = {
|
||||
if (cached) return cached
|
||||
|
||||
const data = await atproto.getRecords(pds, repo, `${collection}.profile`, limit)
|
||||
dataCache.set(cacheKey, data)
|
||||
return data
|
||||
// Extract records array for backward compatibility
|
||||
const records = data.records || data
|
||||
dataCache.set(cacheKey, records)
|
||||
return records
|
||||
},
|
||||
|
||||
// 投稿後にキャッシュを無効化
|
||||
|
@@ -68,9 +68,9 @@ export default function AuthButton({ user, onLogin, onLogout, loading }) {
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={isLoading || !handleInput.trim()}
|
||||
className="auth-button"
|
||||
className={`auth-button ${isLoading ? 'loading' : ''}`}
|
||||
>
|
||||
{isLoading ? 'Loading...' : <i className="fab fa-bluesky"></i>}
|
||||
<i className={isLoading ? "fas fa-spinner" : "fab fa-bluesky"}></i>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
@@ -1,4 +1,8 @@
|
||||
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) {
|
||||
@@ -18,7 +22,7 @@ function getCorrectWebUrl(avatarUrl) {
|
||||
return 'https://bsky.app'
|
||||
}
|
||||
|
||||
export default function ChatRecordList({ chatPairs, apiConfig, user = null, agent = null, onRecordDeleted = null }) {
|
||||
export default function ChatRecordList({ chatPairs, chatHasMore, onLoadMoreChat, apiConfig, user = null, agent = null, onRecordDeleted = null }) {
|
||||
const [expandedRecords, setExpandedRecords] = useState(new Set())
|
||||
|
||||
const toggleJsonView = (key) => {
|
||||
@@ -139,7 +143,14 @@ export default function ChatRecordList({ chatPairs, apiConfig, user = null, agen
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
<div className="message-content">{chatPair.question.value.text}</div>
|
||||
<div className="message-content">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeHighlight]}
|
||||
>
|
||||
{chatPair.question.value.text}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -190,25 +201,31 @@ export default function ChatRecordList({ chatPairs, apiConfig, user = null, agen
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
<div className="message-content">{chatPair.answer.value.text}</div>
|
||||
<div className="message-content">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeHighlight]}
|
||||
>
|
||||
{chatPair.answer.value.text}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Post metadata */}
|
||||
{chatPair.question?.value.post?.url && (
|
||||
<div className="record-meta">
|
||||
<a
|
||||
href={chatPair.question.value.post.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="record-url"
|
||||
>
|
||||
{chatPair.question.value.post.url}
|
||||
</a>
|
||||
</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>
|
||||
)
|
||||
}
|
@@ -1,49 +1,154 @@
|
||||
import React, { useState } from 'react'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import RecordList from './RecordList.jsx'
|
||||
import ChatRecordList from './ChatRecordList.jsx'
|
||||
import ProfileRecordList from './ProfileRecordList.jsx'
|
||||
import LoadingSkeleton from './LoadingSkeleton.jsx'
|
||||
import { logger } from '../utils/logger.js'
|
||||
|
||||
export default function RecordTabs({ langRecords, commentRecords, userComments, chatRecords, userChatRecords, userChatLoading, baseRecords, apiConfig, pageContext, user = null, agent = null, onRecordDeleted = null }) {
|
||||
const [activeTab, setActiveTab] = useState('profiles')
|
||||
export default function RecordTabs({ langRecords, commentRecords, userComments, chatRecords, chatHasMore, onLoadMoreChat, userChatRecords, userChatLoading, baseRecords, apiConfig, pageContext, user = null, agent = null, onRecordDeleted = null }) {
|
||||
// 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
|
||||
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) {
|
||||
// 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 {
|
||||
// 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
|
||||
if (!recordUrl) return false
|
||||
if (!recordUrl) {
|
||||
logger.log('filterRecords: No recordUrl found for record:', record.value?.type)
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
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 {
|
||||
logger.log('filterRecords: URL parsing failed for:', recordUrl)
|
||||
return false
|
||||
}
|
||||
})
|
||||
logger.log('filterRecords: Individual page result:', filtered.length, filtered)
|
||||
return filtered
|
||||
}
|
||||
}
|
||||
|
||||
const filteredLangRecords = filterRecords(langRecords)
|
||||
const filteredCommentRecords = filterRecords(commentRecords)
|
||||
const filteredUserComments = filterRecords(userComments || [])
|
||||
const filteredChatRecords = filterRecords(chatRecords || [])
|
||||
const filteredBaseRecords = filterRecords(baseRecords || [])
|
||||
// Special filter for chat records (which are already processed into pairs)
|
||||
const filterChatRecords = (chatPairs) => {
|
||||
// Ensure chatPairs is an array
|
||||
const chatArray = Array.isArray(chatPairs) ? chatPairs : []
|
||||
|
||||
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 = (baseRecords || []).filter(record => record.value?.type === 'profile')
|
||||
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
|
||||
@@ -53,6 +158,7 @@ export default function RecordTabs({ langRecords, commentRecords, userComments,
|
||||
|
||||
return (
|
||||
<div className="record-tabs">
|
||||
{!isAiPost && (
|
||||
<div className="tab-header">
|
||||
<button
|
||||
className={`tab-btn ${activeTab === 'profiles' ? 'active' : ''}`}
|
||||
@@ -67,13 +173,19 @@ export default function RecordTabs({ langRecords, commentRecords, userComments,
|
||||
className={`tab-btn ${activeTab === 'collection' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('collection')}
|
||||
>
|
||||
chat ({userChatRecords?.length || 0})
|
||||
chat ({filteredChatRecords.length > 0 ? filteredChatRecords.length : (userChatRecords?.length || 0)})
|
||||
</button>
|
||||
<button
|
||||
className={`tab-btn ${activeTab === 'comment' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('comment')}
|
||||
onClick={() => {
|
||||
logger.log('RecordTabs: feedback tab clicked, setting activeTab to comment')
|
||||
setActiveTab('comment')
|
||||
}}
|
||||
>
|
||||
feedback ({filteredCommentRecords.length})
|
||||
feedback ({(() => {
|
||||
logger.log('RecordTabs: feedback tab render - filteredCommentRecords.length:', filteredCommentRecords.length)
|
||||
return filteredCommentRecords.length
|
||||
})()})
|
||||
</button>
|
||||
<button
|
||||
className={`tab-btn ${activeTab === 'users' ? 'active' : ''}`}
|
||||
@@ -88,9 +200,10 @@ export default function RecordTabs({ langRecords, commentRecords, userComments,
|
||||
en ({filteredLangRecords.length})
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="tab-content">
|
||||
{activeTab === 'lang' && (
|
||||
{activeTab === 'lang' && !isAiPost && (
|
||||
!langRecords ? (
|
||||
<LoadingSkeleton count={3} showTitle={true} />
|
||||
) : (
|
||||
@@ -105,7 +218,7 @@ export default function RecordTabs({ langRecords, commentRecords, userComments,
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{activeTab === 'comment' && (
|
||||
{activeTab === 'comment' && !isAiPost && (
|
||||
!commentRecords ? (
|
||||
<LoadingSkeleton count={3} showTitle={true} />
|
||||
) : (
|
||||
@@ -125,7 +238,9 @@ export default function RecordTabs({ langRecords, commentRecords, userComments,
|
||||
<LoadingSkeleton count={2} showTitle={true} />
|
||||
) : (
|
||||
<ChatRecordList
|
||||
chatPairs={userChatRecords}
|
||||
chatPairs={filteredChatRecords.length > 0 ? filteredChatRecords : (Array.isArray(userChatRecords) ? userChatRecords : [])}
|
||||
chatHasMore={filteredChatRecords.length > 0 ? chatHasMore : false}
|
||||
onLoadMoreChat={filteredChatRecords.length > 0 ? onLoadMoreChat : null}
|
||||
apiConfig={apiConfig}
|
||||
user={user}
|
||||
agent={agent}
|
||||
@@ -133,7 +248,7 @@ export default function RecordTabs({ langRecords, commentRecords, userComments,
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{activeTab === 'users' && (
|
||||
{activeTab === 'users' && !isAiPost && (
|
||||
!userComments ? (
|
||||
<LoadingSkeleton count={3} showTitle={true} />
|
||||
) : (
|
||||
@@ -148,7 +263,7 @@ export default function RecordTabs({ langRecords, commentRecords, userComments,
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{activeTab === 'profiles' && (
|
||||
{activeTab === 'profiles' && !isAiPost && (
|
||||
!baseRecords ? (
|
||||
<LoadingSkeleton count={3} showTitle={true} />
|
||||
) : (
|
||||
|
@@ -3,6 +3,7 @@ import { atproto, collections } from '../api/atproto.js'
|
||||
import { getApiConfig } from '../utils/pds.js'
|
||||
import { env } from '../config/env.js'
|
||||
import { getErrorMessage } from '../utils/errorHandler.js'
|
||||
import { logger } from '../utils/logger.js'
|
||||
|
||||
export function useAdminData() {
|
||||
const [adminData, setAdminData] = useState({
|
||||
@@ -13,6 +14,9 @@ export function useAdminData() {
|
||||
})
|
||||
const [langRecords, setLangRecords] = 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 [error, setError] = useState(null)
|
||||
|
||||
@@ -29,30 +33,163 @@ export function useAdminData() {
|
||||
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)
|
||||
// Load all data in parallel with error handling
|
||||
logger.log('useAdminData: Starting API calls...')
|
||||
const [records, lang, comment, chatResult] = await Promise.all([
|
||||
collections.getBase(apiConfig.pds, did, env.collection).catch(err => {
|
||||
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 })
|
||||
setLangRecords(lang)
|
||||
setCommentRecords(comment)
|
||||
setChatRecords(chatPairs)
|
||||
} catch (err) {
|
||||
// Silently fail - no error logging or retry attempts
|
||||
// Log the actual error for debugging
|
||||
logger.error('useAdminData: Error in loadAdminData:', err)
|
||||
setError('silent_failure')
|
||||
} finally {
|
||||
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 {
|
||||
adminData,
|
||||
langRecords,
|
||||
commentRecords,
|
||||
chatRecords,
|
||||
chatHasMore,
|
||||
loading,
|
||||
error,
|
||||
refresh: loadAdminData
|
||||
refresh: loadAdminData,
|
||||
loadMoreChat
|
||||
}
|
||||
}
|
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'
|
||||
import { atproto, collections } from '../api/atproto.js'
|
||||
import { getApiConfig, isSyuIsHandle, getPdsFromHandle } from '../utils/pds.js'
|
||||
import { env } from '../config/env.js'
|
||||
import { logger } from '../utils/logger.js'
|
||||
|
||||
export function useUserData(adminData) {
|
||||
const [userComments, setUserComments] = useState([])
|
||||
@@ -24,13 +25,53 @@ export function useUserData(adminData) {
|
||||
env.collection
|
||||
)
|
||||
|
||||
// 2. Get chat records from ai.syui.log.chat
|
||||
const chatRecords = await collections.getChat(
|
||||
// 2. Get chat records from ai.syui.log.chat and process into pairs
|
||||
const chatResult = await collections.getChat(
|
||||
adminData.apiConfig.pds,
|
||||
adminData.did,
|
||||
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
|
||||
const baseRecords = await collections.getBase(
|
||||
@@ -62,7 +103,7 @@ export function useUserData(adminData) {
|
||||
// Also try to get individual user records from the user list
|
||||
// Currently skipping user list processing since users contain placeholder DIDs
|
||||
if (userListRecords.length > 0 && userListRecords[0].value?.users) {
|
||||
console.log('User list found, but skipping placeholder users for now')
|
||||
logger.log('User list found, but skipping placeholder users for now')
|
||||
|
||||
// Filter out placeholder users
|
||||
const realUsers = userListRecords[0].value.users.filter(user =>
|
||||
@@ -73,7 +114,7 @@ export function useUserData(adminData) {
|
||||
)
|
||||
|
||||
if (realUsers.length > 0) {
|
||||
console.log(`Processing ${realUsers.length} real users`)
|
||||
logger.log(`Processing ${realUsers.length} real users`)
|
||||
|
||||
for (const user of realUsers) {
|
||||
const userHandle = user.handle
|
||||
@@ -100,7 +141,7 @@ export function useUserData(adminData) {
|
||||
userApiConfig = getApiConfig(realPds)
|
||||
} catch (error) {
|
||||
// Fallback to syu.is if bsky.social fails
|
||||
console.warn(`Failed to get PDS for ${userHandle} from bsky.social, trying syu.is:`, error)
|
||||
logger.warn(`Failed to get PDS for ${userHandle} from bsky.social, trying syu.is:`, error)
|
||||
userPds = env.pds
|
||||
userApiConfig = getApiConfig(env.pds)
|
||||
userDid = await atproto.getDid(userPds, userHandle)
|
||||
@@ -124,7 +165,7 @@ export function useUserData(adminData) {
|
||||
try {
|
||||
profile = await atproto.getProfile(userApiConfig.bsky, userDid)
|
||||
} 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
|
||||
@@ -143,11 +184,11 @@ export function useUserData(adminData) {
|
||||
|
||||
allUserComments.push(...enrichedRecords)
|
||||
} catch (userError) {
|
||||
console.warn(`Failed to fetch data for user ${userHandle}:`, userError)
|
||||
logger.warn(`Failed to fetch data for user ${userHandle}:`, userError)
|
||||
}
|
||||
}
|
||||
} 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')
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -3,4 +3,8 @@ import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
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 />)
|
||||
}
|
@@ -3,7 +3,7 @@ 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 // 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) {
|
||||
@@ -76,7 +76,7 @@ class 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
|
||||
}
|
12
pds/index.html
Normal file
12
pds/index.html
Normal 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
27
pds/package.json
Normal 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
128
pds/src/App.css
Normal 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
62
pds/src/App.jsx
Normal 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
|
75
pds/src/components/AtUriBrowser.jsx
Normal file
75
pds/src/components/AtUriBrowser.jsx
Normal 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>`
|
||||
})
|
||||
}
|
130
pds/src/components/AtUriJson.jsx
Normal file
130
pds/src/components/AtUriJson.jsx
Normal 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) ? (
|
||||
<>
|
||||
"
|
||||
<span
|
||||
onClick={() => handleClick(data)}
|
||||
style={{
|
||||
color: 'blue',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline'
|
||||
}}
|
||||
>
|
||||
{data}
|
||||
</span>
|
||||
"
|
||||
</>
|
||||
) : isDid(data) ? (
|
||||
<>
|
||||
"
|
||||
<span
|
||||
onClick={() => handleClick(`at://${data}`)}
|
||||
style={{
|
||||
color: 'blue',
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline'
|
||||
}}
|
||||
>
|
||||
{data}
|
||||
</span>
|
||||
"
|
||||
</>
|
||||
) : URL.canParse(data) ? (
|
||||
<>
|
||||
"
|
||||
<a href={data} rel="noopener noreferrer ugc" target="_blank">
|
||||
{data}
|
||||
</a>
|
||||
"
|
||||
</>
|
||||
) : (
|
||||
`"${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>
|
||||
)
|
||||
}
|
80
pds/src/components/AtUriModal.jsx
Normal file
80
pds/src/components/AtUriModal.jsx
Normal 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>
|
||||
)
|
||||
}
|
111
pds/src/components/AtUriViewer.jsx
Normal file
111
pds/src/components/AtUriViewer.jsx
Normal file
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* 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 {
|
||||
console.log('Loading AT URI:', uri)
|
||||
const atUri = parseAtUri(uri)
|
||||
if (!atUri) {
|
||||
throw new Error('Invalid AT URI')
|
||||
}
|
||||
|
||||
console.log('Parsed AT URI:', {
|
||||
hostname: atUri.hostname,
|
||||
collection: atUri.collection,
|
||||
rkey: atUri.rkey
|
||||
})
|
||||
|
||||
const result = await getRecord(atUri.hostname, atUri.collection, atUri.rkey)
|
||||
|
||||
console.log('getRecord result:', result)
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
|
||||
setRecord(result.data)
|
||||
} catch (err) {
|
||||
console.error('AtUriViewer error:', 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
33
pds/src/config.js
Normal 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
9
pds/src/index.js
Normal 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'
|
165
pds/src/lib/atproto.js
Normal file
165
pds/src/lib/atproto.js
Normal file
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
* 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) {
|
||||
console.log('Failed to resolve from syu.is:', 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) {
|
||||
console.log('Failed to resolve from plc.syu.is:', 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) {
|
||||
console.log('Failed to resolve from plc.directory:', 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 {
|
||||
console.log('getRecord called with:', { did, collection, rkey })
|
||||
|
||||
const identityResult = await resolveIdentity(did)
|
||||
console.log('resolveIdentity result:', identityResult)
|
||||
|
||||
if (!identityResult.success) {
|
||||
return { success: false, error: identityResult.error }
|
||||
}
|
||||
|
||||
const pdsUrl = identityResult.pdsUrl
|
||||
console.log('Using PDS URL:', pdsUrl)
|
||||
|
||||
const client = createAtpClient(pdsUrl)
|
||||
|
||||
const response = await client.com.atproto.repo.getRecord({
|
||||
repo: did,
|
||||
collection,
|
||||
rkey
|
||||
})
|
||||
|
||||
console.log('getRecord response:', response)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response.data,
|
||||
pdsUrl
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('getRecord error:', 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
9
pds/src/main.jsx
Normal 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
10
pds/vite.config.js
Normal 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')
|
||||
}
|
||||
})
|
@@ -3,11 +3,11 @@
|
||||
set -e
|
||||
|
||||
cb=ai.syui.log
|
||||
cl=($cb.chat $cb.user $cb )
|
||||
cl=($cb.chat)
|
||||
f=~/.config/syui/ai/log/config.json
|
||||
|
||||
default_collection="ai.syui.log.chat"
|
||||
default_pds=bsky.social
|
||||
#default_collection="ai.syui.log.chat"
|
||||
default_pds=syu.is
|
||||
default_did=`cat $f|jq -r .admin.did`
|
||||
default_token=`cat $f|jq -r .admin.access_jwt`
|
||||
default_refresh=`cat $f|jq -r .admin.refresh_jwt`
|
||||
|
29
scpt/run.zsh
29
scpt/run.zsh
@@ -2,9 +2,10 @@
|
||||
|
||||
function _env() {
|
||||
d=${0:a:h}
|
||||
ailog=$d/target/debug/ailog
|
||||
ailog=$d/target/release/ailog
|
||||
oauth=$d/oauth
|
||||
myblog=$d/my-blog
|
||||
pds=$d/pds
|
||||
port=4173
|
||||
#source $oauth/.env.production
|
||||
case $OSTYPE in
|
||||
@@ -22,7 +23,7 @@ function _deploy_ailog() {
|
||||
function _server() {
|
||||
lsof -ti:$port | xargs kill -9 2>/dev/null || true
|
||||
cd $d/my-blog
|
||||
cargo build
|
||||
cargo build --release
|
||||
cp -rf $ailog $CARGO_HOME/bin/
|
||||
$ailog build
|
||||
$ailog serve --port $port
|
||||
@@ -43,8 +44,24 @@ function _oauth_build() {
|
||||
#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() {
|
||||
cargo build
|
||||
cargo build --release
|
||||
cp -rf $ailog $CARGO_HOME/bin/
|
||||
AILOG_DEBUG_ALL=1 $ailog stream start my-blog
|
||||
}
|
||||
@@ -64,6 +81,12 @@ case "${1:-serve}" in
|
||||
oauth|o)
|
||||
_oauth_build
|
||||
;;
|
||||
pds|p)
|
||||
_pds_build
|
||||
;;
|
||||
pds-server|ps)
|
||||
_pds_server
|
||||
;;
|
||||
n)
|
||||
oauth=$d/oauth_old
|
||||
_oauth_build
|
||||
|
@@ -1,3 +1,4 @@
|
||||
pub mod oauth;
|
||||
pub mod client;
|
||||
pub mod comment_sync;
|
||||
pub mod comment_sync;
|
||||
pub mod profile;
|
215
src/atproto/profile.rs
Normal file
215
src/atproto/profile.rs
Normal 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");
|
||||
}
|
||||
}
|
@@ -245,7 +245,7 @@ pub async fn init_with_pds(pds_override: Option<String>) -> Result<()> {
|
||||
}
|
||||
} else {
|
||||
// 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()
|
||||
} else {
|
||||
"https://bsky.social".to_string()
|
||||
|
706
src/commands/interactive.rs
Normal file
706
src/commands/interactive.rs
Normal file
@@ -0,0 +1,706 @@
|
||||
use anyhow::Result;
|
||||
use chrono::{DateTime, Utc, Datelike};
|
||||
use std::path::PathBuf;
|
||||
use serde_json::{json, Value};
|
||||
use crate::commands::auth::{AuthConfig, load_config_with_refresh};
|
||||
use toml::Value as TomlValue;
|
||||
use rustyline::DefaultEditor;
|
||||
use rand::Rng;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct BlogConfig {
|
||||
base_url: String,
|
||||
content_dir: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ProfileConfig {
|
||||
handle: String,
|
||||
did: String,
|
||||
display_name: String,
|
||||
avatar_url: String,
|
||||
profile_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ProfilesConfig {
|
||||
user: ProfileConfig,
|
||||
ai: ProfileConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PathsConfig {
|
||||
claude_paths: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AppConfig {
|
||||
blog: BlogConfig,
|
||||
profiles: ProfilesConfig,
|
||||
paths: PathsConfig,
|
||||
}
|
||||
|
||||
pub async fn run() -> Result<()> {
|
||||
println!("🤖 Interactive Blog Writer");
|
||||
println!("Type your title and questions to create a conversation blog post.");
|
||||
println!("Features: ← → for cursor movement, ↑ ↓ for history, Ctrl+C to cancel");
|
||||
println!("Type 'end' to finish and publish.\n");
|
||||
|
||||
// Initialize rustyline editor with history support
|
||||
let mut rl = DefaultEditor::new()?;
|
||||
|
||||
// Try to load history from file
|
||||
let history_file = std::env::temp_dir().join("ailog_history.txt");
|
||||
let _ = rl.load_history(&history_file);
|
||||
|
||||
// Get title
|
||||
let title = match rl.readline("Title? ") {
|
||||
Ok(line) => line.trim().to_string(),
|
||||
Err(_) => {
|
||||
println!("Cancelled.");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
if title.is_empty() {
|
||||
println!("Title cannot be empty. Exiting.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Create conversation
|
||||
let mut conversation = Vec::new();
|
||||
|
||||
loop {
|
||||
|
||||
// Get question
|
||||
let question = match rl.readline("\n質問は? ") {
|
||||
Ok(line) => line.trim().to_string(),
|
||||
Err(_) => {
|
||||
println!("Cancelled.");
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
if question.is_empty() || question == "end" {
|
||||
break;
|
||||
}
|
||||
|
||||
println!("\n🤖 Thinking...\n");
|
||||
|
||||
// Get Claude response
|
||||
let response = match get_claude_response(&question).await {
|
||||
Ok(resp) => resp,
|
||||
Err(e) => {
|
||||
println!("Error getting Claude response: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
println!("Claude: {}\n", response);
|
||||
// Get continue input
|
||||
let continue_input = match rl.readline("Press Enter to continue, or type 'end' to finish: ") {
|
||||
Ok(line) => line.trim().to_string(),
|
||||
Err(_) => {
|
||||
println!("Cancelled.");
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// Store conversation pair
|
||||
conversation.push(ConversationPair {
|
||||
question: question.clone(),
|
||||
answer: response.clone(),
|
||||
});
|
||||
|
||||
if continue_input == "end" {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if conversation.is_empty() {
|
||||
println!("No conversation to save. Exiting.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Generate post
|
||||
println!("🔧 Generating post details...");
|
||||
let app_config = load_app_config().await?;
|
||||
let now = Utc::now();
|
||||
let date = now.format("%Y-%m-%d").to_string();
|
||||
let hash = generate_hash(&title);
|
||||
let filename = format!("{}-{}.md", date, hash);
|
||||
let url = format!("{}/posts/{}", app_config.blog.base_url, filename.replace(".md", ".html"));
|
||||
println!("📝 Post details - Date: {}, Hash: {}, File: {}", date, hash, filename);
|
||||
|
||||
// Create markdown file
|
||||
println!("📝 Creating markdown file: {}", filename);
|
||||
match create_post_file(&title, &date, &hash, &conversation, &filename).await {
|
||||
Ok(_) => println!("✅ Markdown file created successfully"),
|
||||
Err(e) => {
|
||||
println!("❌ Failed to create markdown file: {}", e);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
|
||||
// Post to atproto
|
||||
post_to_atproto(&title, &conversation, &url, &now).await?;
|
||||
|
||||
// Save history before exiting
|
||||
let _ = rl.save_history(&history_file);
|
||||
|
||||
println!("✅ Blog post created: {}", filename);
|
||||
println!("📝 URL: {}", url);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ConversationPair {
|
||||
question: String,
|
||||
answer: String,
|
||||
}
|
||||
|
||||
async fn get_claude_response(question: &str) -> Result<String> {
|
||||
println!("🤖 Asking Claude...");
|
||||
|
||||
// Load system prompt from config
|
||||
let system_prompt = load_system_prompt().await.unwrap_or_else(|_| {
|
||||
"あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。".to_string()
|
||||
});
|
||||
|
||||
// Method 1: Try stdin input (most direct)
|
||||
println!("🔄 Trying stdin method...");
|
||||
match try_claude_stdin(question, &system_prompt).await {
|
||||
Ok(response) if !response.trim().is_empty() => {
|
||||
return Ok(response);
|
||||
}
|
||||
Ok(_) => println!("⚠️ Empty response from stdin method"),
|
||||
Err(e) => println!("⚠️ Stdin method failed: {}", e),
|
||||
}
|
||||
|
||||
// Method 2: Try file input
|
||||
println!("🔄 Trying file method...");
|
||||
match try_claude_file(question, &system_prompt).await {
|
||||
Ok(response) if !response.trim().is_empty() => {
|
||||
return Ok(response);
|
||||
}
|
||||
Ok(_) => println!("⚠️ Empty response from file method"),
|
||||
Err(e) => println!("⚠️ File method failed: {}", e),
|
||||
}
|
||||
|
||||
// Fallback - provide a more engaging response using character
|
||||
println!("⚠️ Claude Code not available, using character-based fallback");
|
||||
|
||||
// Generate contextual response based on question content with character
|
||||
let current_year = Utc::now().year();
|
||||
let response = if question.contains("ゲーム") || question.contains("game") || question.contains("npc") || question.contains("NPC") {
|
||||
format!("わあ!ゲームの話だね!アイ、ゲームのAIってすっごく面白いと思う!\n\n{}\n\nアイが知ってることだと、最近のゲームはNPCがお話できるようになってるんだって!**Inworld AI**っていうのがUE5で使えるようになってるし、**Unity Muse**も{}年から本格的に始まってるんだよ!\n\nアイが特に面白いと思うのは、**MCP**っていうのを使うと:\n- GitHub MCPでゲームのファイル管理ができる\n- Weather MCPでリアルタイムのお天気が連動する\n- Slack MCPでチーム開発が効率化される\n\nスタンフォードの研究では、ChatGPTベースのAI住民が自分で街を作って生活してるのを見たことがあるの!数年後にはNPCの概念が根本的に変わりそうで、わくわくしちゃう!\n\nUE5への統合、どんな機能から試したいの?アイも一緒に考えたい!", question, current_year)
|
||||
} else if question.contains("AI") || question.contains("ai") || question.contains("MCP") || question.contains("mcp") {
|
||||
format!("AIとMCPの話!アイの得意分野だよ!\n\n{}\n\n{}年の状況だと、MCP市場が拡大してて、実用的なサーバーが数多く使えるようになってるの!\n\nアイが知ってる開発系では:\n- **GitHub MCP**: PR作成とリポジトリ管理が自動化\n- **Docker MCP**: コンテナ操作をAIが代行\n- **PostgreSQL MCP**: データベース設計・最適化を支援\n\nクリエイティブ系では:\n- **Blender MCP**: 3Dモデリングの自動化\n- **Figma MCP**: デザインからコード変換\n\n**Zapier MCP**なんて数千のアプリと連携できるから、もう手作業でやってる場合じゃないよね!\n\nアイは小さい物質のことも知ってるから、どの分野でのMCP活用を考えてるのか教えて!具体的なユースケースがあると、もっと詳しくお話できるよ!", question, current_year)
|
||||
} else {
|
||||
format!("なるほど!面白い話題だね!\n\n{}\n\nアイが思うに、この手の技術って急速に進歩してるから、具体的な製品名とか実例を交えて話した方が分かりやすいかもしれないの!\n\n最近だと、AI関連のツールやプロトコルがかなり充実してきてて、実用レベルのものが増えてるんだよ!\n\nアイは宇宙とかAIとか、難しい話も知ってるから、特にどんな角度から深掘りしたいの?実装面?それとも将来的な可能性とか?アイと一緒に考えよう!", question)
|
||||
};
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
async fn load_app_config() -> Result<AppConfig> {
|
||||
let config_path = PathBuf::from("./my-blog/config.toml");
|
||||
let config_content = std::fs::read_to_string(config_path)?;
|
||||
let config: AppConfig = toml::from_str(&config_content)?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
async fn load_system_prompt() -> Result<String> {
|
||||
let config_path = PathBuf::from("./my-blog/config.toml");
|
||||
let config_content = std::fs::read_to_string(config_path)?;
|
||||
let config: TomlValue = toml::from_str(&config_content)?;
|
||||
|
||||
if let Some(ai_section) = config.get("ai") {
|
||||
if let Some(system_prompt) = ai_section.get("system_prompt") {
|
||||
if let Some(prompt_str) = system_prompt.as_str() {
|
||||
return Ok(prompt_str.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
Ok("あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。".to_string())
|
||||
}
|
||||
|
||||
async fn try_claude_stdin(question: &str, _system_prompt: &str) -> Result<String> {
|
||||
use std::process::{Command, Stdio};
|
||||
use std::io::Write;
|
||||
|
||||
// Load Claude command paths from config
|
||||
let app_config = load_app_config().await?;
|
||||
let claude_paths = &app_config.paths.claude_paths;
|
||||
|
||||
let mut last_error = None;
|
||||
|
||||
for claude_path in claude_paths {
|
||||
match Command::new(claude_path)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn() {
|
||||
Ok(mut child) => {
|
||||
if let Some(stdin) = child.stdin.as_mut() {
|
||||
let current_year = Utc::now().year();
|
||||
// キャラクター設定を読み込み
|
||||
let system_prompt = load_system_prompt().await.unwrap_or_default();
|
||||
let blog_prompt = format!(
|
||||
r#"{}
|
||||
|
||||
**質問**: "{}"
|
||||
|
||||
以下の要件で技術ブログ記事として回答してください:
|
||||
|
||||
**技術要件:**
|
||||
- 最新の技術動向({}年)と具体例
|
||||
- 実装可能なコード例(言語やツールを明記)
|
||||
- 複数の解決策の比較検討
|
||||
- セキュリティとパフォーマンスの考慮事項
|
||||
|
||||
**表現要件:**
|
||||
- キャラクターの個性を活かした親しみやすい説明
|
||||
- 技術に対する個人的な意見や考えを含める
|
||||
- 難しい概念も分かりやすく説明
|
||||
- 読者との対話的な文章
|
||||
|
||||
**Markdown記法:**
|
||||
- コードブロックは言語指定付き(```typescript, ```python など)
|
||||
- 表は | を使用したMarkdown形式
|
||||
- 見出しは適切な階層構造(#, ##, ###)
|
||||
- リストは - または 1. 形式
|
||||
- mermaidやその他の図も積極的に使用
|
||||
|
||||
専門的な内容を保ちながら、キャラクターの視点から技術の面白さや可能性について語ってください。"#, system_prompt, question, current_year);
|
||||
|
||||
writeln!(stdin, "{}", blog_prompt)?;
|
||||
stdin.flush()?;
|
||||
// Close stdin to signal end of input
|
||||
drop(child.stdin.take());
|
||||
}
|
||||
|
||||
let output = child.wait_with_output()?;
|
||||
|
||||
if output.status.success() {
|
||||
let response = String::from_utf8_lossy(&output.stdout);
|
||||
return Ok(response.trim().to_string());
|
||||
} else {
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
last_error = Some(anyhow::anyhow!("Claude stdin error: {}", error));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
last_error = Some(e.into());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_error.unwrap_or_else(|| anyhow::anyhow!("No Claude command found")))
|
||||
}
|
||||
|
||||
async fn try_claude_file(question: &str, _system_prompt: &str) -> Result<String> {
|
||||
use std::process::Command;
|
||||
use std::fs;
|
||||
|
||||
// Create temporary directory for communication
|
||||
let temp_dir = std::env::temp_dir().join("ailog_claude");
|
||||
fs::create_dir_all(&temp_dir)?;
|
||||
|
||||
let question_file = temp_dir.join("question.md");
|
||||
|
||||
// Write question to file with blog prompt
|
||||
let current_year = Utc::now().year();
|
||||
let system_prompt = load_system_prompt().await.unwrap_or_default();
|
||||
fs::write(&question_file, format!(
|
||||
r#"{}
|
||||
|
||||
**質問**: "{}"
|
||||
|
||||
以下の要件で技術ブログ記事として回答してください:
|
||||
|
||||
**技術要件:**
|
||||
- 最新の技術動向({}年)と具体例
|
||||
- 実装可能なコード例(言語やツールを明記)
|
||||
- 複数の解決策の比較検討
|
||||
- セキュリティとパフォーマンスの考慮事項
|
||||
|
||||
**表現要件:**
|
||||
- キャラクターの個性を活かした親しみやすい説明
|
||||
- 技術に対する個人的な意見や考えを含める
|
||||
- 難しい概念も分かりやすく説明
|
||||
- 読者との対話的な文章
|
||||
|
||||
**Markdown記法:**
|
||||
- コードブロックは言語指定付き(```typescript, ```python など)
|
||||
- 表は | を使用したMarkdown形式
|
||||
- 見出しは適切な階層構造(#, ##, ###)
|
||||
- リストは - または 1. 形式
|
||||
- mermaidやその他の図も積極的に使用
|
||||
|
||||
専門的な内容を保ちながら、キャラクターの視点から技術の面白さや可能性について語ってください。"#, system_prompt, question, current_year))?;
|
||||
|
||||
// Load Claude command paths from config
|
||||
let app_config = load_app_config().await?;
|
||||
let claude_paths = &app_config.paths.claude_paths;
|
||||
|
||||
let mut last_error = None;
|
||||
|
||||
for claude_path in claude_paths {
|
||||
match Command::new(claude_path)
|
||||
.arg(question_file.to_str().unwrap())
|
||||
.output() {
|
||||
Ok(output) if output.status.success() => {
|
||||
let _ = fs::remove_file(&question_file);
|
||||
let response = String::from_utf8_lossy(&output.stdout);
|
||||
return Ok(response.trim().to_string());
|
||||
}
|
||||
Ok(output) => {
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
last_error = Some(anyhow::anyhow!("Claude file error: {}", error));
|
||||
}
|
||||
Err(e) => {
|
||||
last_error = Some(e.into());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up temporary files
|
||||
let _ = fs::remove_file(&question_file);
|
||||
|
||||
Err(last_error.unwrap_or_else(|| anyhow::anyhow!("No Claude command found")))
|
||||
}
|
||||
|
||||
fn generate_hash(title: &str) -> String {
|
||||
// Simple hash generation from title
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
let mut hasher = DefaultHasher::new();
|
||||
title.hash(&mut hasher);
|
||||
format!("{:x}", hasher.finish())[..8].to_string()
|
||||
}
|
||||
|
||||
async fn create_post_file(
|
||||
title: &str,
|
||||
date: &str,
|
||||
hash: &str,
|
||||
conversation: &[ConversationPair],
|
||||
filename: &str
|
||||
) -> Result<()> {
|
||||
// Load profile information from config
|
||||
let app_config = load_app_config().await?;
|
||||
let user_profile = &app_config.profiles.user;
|
||||
let ai_profile = &app_config.profiles.ai;
|
||||
let content_dir = PathBuf::from(&app_config.blog.content_dir);
|
||||
std::fs::create_dir_all(&content_dir)?;
|
||||
|
||||
let file_path = content_dir.join(filename);
|
||||
println!("📂 Writing to path: {}", file_path.display());
|
||||
|
||||
let mut content = format!(
|
||||
r#"---
|
||||
title: "{}"
|
||||
slug: "{}"
|
||||
date: "{}"
|
||||
tags: ["ai", "conversation"]
|
||||
draft: false
|
||||
extra:
|
||||
type: "ai"
|
||||
---
|
||||
|
||||
"#,
|
||||
title, hash, date
|
||||
);
|
||||
|
||||
// Add conversation metadata
|
||||
content.push_str("<!-- AI Conversation Metadata -->\n");
|
||||
content.push_str(&format!("<!-- Total exchanges: {} -->\n\n", conversation.len()));
|
||||
|
||||
// Add conversation content with chat-style HTML
|
||||
for (i, pair) in conversation.iter().enumerate() {
|
||||
if i > 0 {
|
||||
content.push_str("\n<div class=\"chat-separator\"></div>\n\n");
|
||||
}
|
||||
|
||||
// User message (question)
|
||||
content.push_str(&format!(r#"<div class="chat-message user-message comment-style">
|
||||
<div class="message-header">
|
||||
<div class="avatar">
|
||||
<img src="{}" alt="syui avatar" class="profile-avatar">
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<div class="display-name">{}</div>
|
||||
<div class="handle">
|
||||
<a href="{}" target="_blank" rel="noopener noreferrer" class="handle-link">@{}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-content">
|
||||
"#,
|
||||
user_profile.avatar_url,
|
||||
user_profile.display_name,
|
||||
user_profile.profile_url,
|
||||
user_profile.handle
|
||||
));
|
||||
content.push_str(&pair.question);
|
||||
content.push_str("\n </div>\n</div>\n\n");
|
||||
|
||||
// AI message (answer)
|
||||
content.push_str(&format!(r#"<div class="chat-message ai-message comment-style">
|
||||
<div class="message-header">
|
||||
<div class="avatar">
|
||||
<img src="{}" alt="ai avatar" class="profile-avatar">
|
||||
</div>
|
||||
<div class="user-info">
|
||||
<div class="display-name">{}</div>
|
||||
<div class="handle">
|
||||
<a href="{}" target="_blank" rel="noopener noreferrer" class="handle-link">@{}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-content">
|
||||
"#,
|
||||
ai_profile.avatar_url,
|
||||
ai_profile.display_name,
|
||||
ai_profile.profile_url,
|
||||
ai_profile.handle
|
||||
));
|
||||
content.push_str(&pair.answer);
|
||||
content.push_str("\n </div>\n</div>\n\n");
|
||||
}
|
||||
|
||||
std::fs::write(&file_path, content)?;
|
||||
println!("📄 Created: {}", filename);
|
||||
println!("✅ File exists: {}", file_path.exists());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn post_to_atproto(
|
||||
title: &str,
|
||||
conversation: &[ConversationPair],
|
||||
url: &str,
|
||||
timestamp: &DateTime<Utc>
|
||||
) -> Result<()> {
|
||||
println!("📡 Posting to atproto...");
|
||||
|
||||
// Load auth config once
|
||||
let config = load_config_with_refresh().await?;
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Simple duplicate check - just warn if there might be duplicates
|
||||
if let Err(e) = check_for_duplicates(&client, &config, conversation, url).await {
|
||||
println!("⚠️ Duplicate check warning: {}", e);
|
||||
// Continue posting anyway
|
||||
}
|
||||
|
||||
// Get user and AI profile information
|
||||
let user_profile = get_user_profile(&config).await?;
|
||||
let ai_profile = get_ai_profile(&client, &config).await?;
|
||||
|
||||
for (i, pair) in conversation.iter().enumerate() {
|
||||
println!(" 📤 Posting exchange {}/{}...", i + 1, conversation.len());
|
||||
|
||||
// Create base rkey for this conversation pair with random component
|
||||
let mut rng = rand::thread_rng();
|
||||
let random_suffix: u32 = rng.gen_range(1000..9999);
|
||||
let base_rkey = format!("{}-{}-{}", timestamp.format("%Y-%m-%dT%H-%M-%S-%3fZ"), i, random_suffix);
|
||||
|
||||
// Post question record first
|
||||
print!(" 📝 Question... ");
|
||||
let question_record = json!({
|
||||
"$type": "ai.syui.log.chat",
|
||||
"post": {
|
||||
"url": url,
|
||||
"date": timestamp.to_rfc3339(),
|
||||
"slug": "",
|
||||
"tags": [],
|
||||
"title": title,
|
||||
"language": "ja",
|
||||
"type": "ai"
|
||||
},
|
||||
"text": pair.question,
|
||||
"type": "question",
|
||||
"author": user_profile,
|
||||
"createdAt": timestamp.to_rfc3339()
|
||||
});
|
||||
|
||||
store_atproto_record(&client, &config, &config.collections.chat(), &question_record, &base_rkey).await?;
|
||||
println!("✅");
|
||||
|
||||
// Wait a moment between posts
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
|
||||
// Post answer record
|
||||
print!(" 🤖 Answer... ");
|
||||
let answer_rkey = format!("{}-answer", base_rkey);
|
||||
let answer_record = json!({
|
||||
"$type": "ai.syui.log.chat",
|
||||
"post": {
|
||||
"url": url,
|
||||
"date": timestamp.to_rfc3339(),
|
||||
"slug": "",
|
||||
"tags": [],
|
||||
"title": title,
|
||||
"language": "ja",
|
||||
"type": "ai"
|
||||
},
|
||||
"text": pair.answer,
|
||||
"type": "answer",
|
||||
"author": ai_profile,
|
||||
"createdAt": timestamp.to_rfc3339()
|
||||
});
|
||||
|
||||
store_atproto_record(&client, &config, &config.collections.chat(), &answer_record, &answer_rkey).await?;
|
||||
println!("✅");
|
||||
|
||||
// Wait between conversation pairs
|
||||
if i < conversation.len() - 1 {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await;
|
||||
}
|
||||
}
|
||||
|
||||
println!("✅ Posted to atproto");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_user_profile(config: &AuthConfig) -> Result<Value> {
|
||||
use crate::atproto::profile::ProfileFetcher;
|
||||
|
||||
// Load user config from app config
|
||||
let app_config = load_app_config().await?;
|
||||
let user_profile = &app_config.profiles.user;
|
||||
|
||||
// Try to fetch profile dynamically
|
||||
let profile_fetcher = ProfileFetcher::new();
|
||||
match profile_fetcher.fetch_profile_from_handle(&user_profile.handle, &config.admin.pds).await {
|
||||
Ok(profile) => {
|
||||
Ok(json!({
|
||||
"did": profile.did,
|
||||
"handle": profile.handle,
|
||||
"displayName": profile.display_name.unwrap_or_else(|| user_profile.display_name.clone()),
|
||||
"avatar": profile.avatar.unwrap_or_else(|| user_profile.avatar_url.clone())
|
||||
}))
|
||||
}
|
||||
Err(e) => {
|
||||
println!("⚠️ Failed to fetch user profile dynamically: {}, using config defaults", e);
|
||||
Ok(json!({
|
||||
"did": user_profile.did,
|
||||
"handle": user_profile.handle,
|
||||
"displayName": user_profile.display_name,
|
||||
"avatar": user_profile.avatar_url
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_ai_profile(_client: &reqwest::Client, config: &AuthConfig) -> Result<Value> {
|
||||
use crate::atproto::profile::ProfileFetcher;
|
||||
|
||||
// Load AI config from app config
|
||||
let app_config = load_app_config().await?;
|
||||
let ai_profile = &app_config.profiles.ai;
|
||||
|
||||
// Try to fetch profile dynamically
|
||||
let profile_fetcher = ProfileFetcher::new();
|
||||
match profile_fetcher.fetch_profile_from_handle(&ai_profile.handle, &config.admin.pds).await {
|
||||
Ok(profile) => {
|
||||
Ok(json!({
|
||||
"did": profile.did,
|
||||
"handle": profile.handle,
|
||||
"displayName": profile.display_name.unwrap_or_else(|| ai_profile.display_name.clone()),
|
||||
"avatar": profile.avatar.unwrap_or_else(|| ai_profile.avatar_url.clone())
|
||||
}))
|
||||
}
|
||||
Err(e) => {
|
||||
println!("⚠️ Failed to fetch AI profile dynamically: {}, using config defaults", e);
|
||||
Ok(json!({
|
||||
"did": ai_profile.did,
|
||||
"handle": ai_profile.handle,
|
||||
"displayName": ai_profile.display_name,
|
||||
"avatar": ai_profile.avatar_url
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_for_duplicates(
|
||||
client: &reqwest::Client,
|
||||
config: &AuthConfig,
|
||||
_conversation: &[ConversationPair],
|
||||
_url: &str,
|
||||
) -> Result<()> {
|
||||
// Simple check - just get recent records to warn about potential duplicates
|
||||
let chat_collection = format!("{}.chat", config.collections.base);
|
||||
let list_url = format!("{}/xrpc/com.atproto.repo.listRecords", config.admin.pds);
|
||||
|
||||
let response = client
|
||||
.get(&list_url)
|
||||
.query(&[
|
||||
("repo", config.admin.did.as_str()),
|
||||
("collection", chat_collection.as_str()),
|
||||
("limit", "10"), // Only check last 10 records
|
||||
])
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow::anyhow!("Failed to fetch existing records"));
|
||||
}
|
||||
|
||||
let records: serde_json::Value = response.json().await?;
|
||||
let record_count = records["records"].as_array().map(|arr| arr.len()).unwrap_or(0);
|
||||
|
||||
if record_count > 0 {
|
||||
println!("ℹ️ Found {} recent chat records", record_count);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn store_atproto_record(
|
||||
client: &reqwest::Client,
|
||||
config: &AuthConfig,
|
||||
collection: &str,
|
||||
record_data: &Value,
|
||||
rkey: &str,
|
||||
) -> Result<()> {
|
||||
let url = format!("{}/xrpc/com.atproto.repo.putRecord", config.admin.pds);
|
||||
|
||||
let put_request = json!({
|
||||
"repo": config.admin.did,
|
||||
"collection": collection,
|
||||
"rkey": rkey,
|
||||
"record": record_data
|
||||
});
|
||||
|
||||
let response = client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", config.admin.access_jwt))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&put_request)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let error_text = response.text().await?;
|
||||
|
||||
// Check if it's a conflict error (record already exists)
|
||||
if status == 409 || error_text.contains("InvalidSwap") || error_text.contains("RecordAlreadyExists") {
|
||||
println!("⚠️ Record with rkey '{}' already exists, skipping", rkey);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
return Err(anyhow::anyhow!("Failed to post record: {} - {}", status, error_text));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
@@ -6,4 +6,5 @@ pub mod clean;
|
||||
pub mod doc;
|
||||
pub mod auth;
|
||||
pub mod stream;
|
||||
pub mod oauth;
|
||||
pub mod oauth;
|
||||
pub mod interactive;
|
@@ -328,7 +328,7 @@ async fn serve_file(path: &str) -> Result<(&'static str, Vec<u8>, &'static str)>
|
||||
// Remove query parameters from path
|
||||
let clean_path = path.split('?').next().unwrap_or(path);
|
||||
|
||||
let file_path = if clean_path == "/" {
|
||||
let mut file_path = if clean_path == "/" {
|
||||
PathBuf::from("public/index.html")
|
||||
} else {
|
||||
PathBuf::from("public").join(clean_path.trim_start_matches('/'))
|
||||
@@ -337,9 +337,42 @@ async fn serve_file(path: &str) -> Result<(&'static str, Vec<u8>, &'static str)>
|
||||
println!("Serving file: {}", file_path.display());
|
||||
|
||||
// Check if file exists and get metadata
|
||||
let metadata = tokio::fs::metadata(&file_path).await?;
|
||||
if !metadata.is_file() {
|
||||
return Err(anyhow::anyhow!("Not a file: {}", file_path.display()));
|
||||
let metadata = tokio::fs::metadata(&file_path).await;
|
||||
|
||||
match metadata {
|
||||
Ok(meta) if meta.is_file() => {
|
||||
// File exists, proceed normally
|
||||
}
|
||||
Ok(meta) if meta.is_dir() => {
|
||||
// Directory exists, try to serve index.html
|
||||
file_path = file_path.join("index.html");
|
||||
println!("Directory found, trying index.html: {}", file_path.display());
|
||||
let index_metadata = tokio::fs::metadata(&file_path).await?;
|
||||
if !index_metadata.is_file() {
|
||||
return Err(anyhow::anyhow!("No index.html in directory: {}", file_path.display()));
|
||||
}
|
||||
}
|
||||
Ok(_) => {
|
||||
return Err(anyhow::anyhow!("Not a file: {}", file_path.display()));
|
||||
}
|
||||
Err(e) => {
|
||||
// Try adding index.html to the original path
|
||||
let index_path = PathBuf::from("public")
|
||||
.join(clean_path.trim_start_matches('/'))
|
||||
.join("index.html");
|
||||
|
||||
println!("File not found, trying index.html: {}", index_path.display());
|
||||
let index_metadata = tokio::fs::metadata(&index_path).await;
|
||||
if let Ok(meta) = index_metadata {
|
||||
if meta.is_file() {
|
||||
file_path = index_path;
|
||||
} else {
|
||||
return Err(anyhow::anyhow!("Original error: {}", e));
|
||||
}
|
||||
} else {
|
||||
return Err(anyhow::anyhow!("File not found: {}", file_path.display()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (content_type, cache_control) = match file_path.extension().and_then(|ext| ext.to_str()) {
|
||||
|
@@ -86,6 +86,12 @@ impl Generator {
|
||||
}
|
||||
}
|
||||
|
||||
// Generate PDS page
|
||||
self.generate_pds_page().await?;
|
||||
|
||||
// Generate Game page
|
||||
self.generate_game_page().await?;
|
||||
|
||||
println!("{} {} posts", "Generated".cyan(), posts.len());
|
||||
|
||||
Ok(())
|
||||
@@ -235,6 +241,7 @@ impl Generator {
|
||||
.unwrap_or_default(),
|
||||
translations: None,
|
||||
ai_comment: None,
|
||||
extra: frontmatter.get("extra").cloned(),
|
||||
};
|
||||
|
||||
// Auto-translate if enabled and post is in Japanese
|
||||
@@ -341,7 +348,8 @@ impl Generator {
|
||||
"ai_comment": enhanced_post.ai_comment,
|
||||
"markdown_url": markdown_url,
|
||||
"translation_url": translation_urls.first(),
|
||||
"language": self.config.site.language
|
||||
"language": self.config.site.language,
|
||||
"extra": enhanced_post.extra
|
||||
}));
|
||||
|
||||
let html = self.template_engine.render_with_context("post.html", &context)?;
|
||||
@@ -489,6 +497,54 @@ impl Generator {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn generate_pds_page(&self) -> Result<()> {
|
||||
let public_dir = self.base_path.join("public");
|
||||
let pds_dir = public_dir.join("pds");
|
||||
fs::create_dir_all(&pds_dir)?;
|
||||
|
||||
// Generate PDS page using the pds.html template
|
||||
let config_with_timestamp = self.create_config_with_timestamp()?;
|
||||
let mut context = tera::Context::new();
|
||||
context.insert("config", &config_with_timestamp);
|
||||
context.insert("site", &self.config.site);
|
||||
context.insert("page", &serde_json::json!({
|
||||
"title": "AT URI Browser",
|
||||
"description": "AT Protocol レコードをブラウズし、分散SNSのコンテンツを探索できます"
|
||||
}));
|
||||
|
||||
let rendered_content = self.template_engine.render("pds.html", &context)?;
|
||||
let output_path = pds_dir.join("index.html");
|
||||
fs::write(output_path, rendered_content)?;
|
||||
|
||||
println!("{} PDS page", "Generated".cyan());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn generate_game_page(&self) -> Result<()> {
|
||||
let public_dir = self.base_path.join("public");
|
||||
let game_dir = public_dir.join("game");
|
||||
fs::create_dir_all(&game_dir)?;
|
||||
|
||||
// Generate Game page using the game.html template
|
||||
let config_with_timestamp = self.create_config_with_timestamp()?;
|
||||
let mut context = tera::Context::new();
|
||||
context.insert("config", &config_with_timestamp);
|
||||
context.insert("site", &self.config.site);
|
||||
context.insert("page", &serde_json::json!({
|
||||
"title": "Game",
|
||||
"description": "Play the game with AT Protocol authentication"
|
||||
}));
|
||||
|
||||
let rendered_content = self.template_engine.render("game.html", &context)?;
|
||||
let output_path = game_dir.join("index.html");
|
||||
fs::write(output_path, rendered_content)?;
|
||||
|
||||
println!("{} Game page", "Generated".cyan());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_plain_text(&self, html_content: &str) -> String {
|
||||
// Remove HTML tags and extract plain text
|
||||
let mut text = String::new();
|
||||
@@ -531,8 +587,10 @@ pub struct Post {
|
||||
pub tags: Vec<String>,
|
||||
pub translations: Option<Vec<Translation>>,
|
||||
pub ai_comment: Option<String>,
|
||||
pub extra: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct Translation {
|
||||
pub lang: String,
|
||||
|
11
src/main.rs
11
src/main.rs
@@ -107,6 +107,8 @@ enum Commands {
|
||||
#[command(subcommand)]
|
||||
command: OauthCommands,
|
||||
},
|
||||
/// Interactive blog writing mode (default)
|
||||
Interactive,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
@@ -189,10 +191,8 @@ async fn main() -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Require subcommand if no version flag
|
||||
let command = cli.command.ok_or_else(|| {
|
||||
anyhow::anyhow!("No subcommand provided. Use --help for usage information.")
|
||||
})?;
|
||||
// If no subcommand provided, start interactive mode
|
||||
let command = cli.command.unwrap_or(Commands::Interactive);
|
||||
|
||||
match command {
|
||||
Commands::Init { path } => {
|
||||
@@ -280,6 +280,9 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
}
|
||||
}
|
||||
Commands::Interactive => {
|
||||
commands::interactive::run().await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
Reference in New Issue
Block a user