23 Commits

Author SHA1 Message Date
cb8b0582e9 rm log 2025-08-01 21:25:52 +09:00
85494944ad rm log 2025-08-01 20:36:35 +09:00
5aeeba106a add post 2025-07-30 19:30:01 +09:00
f1e76ab31f fix post 2025-07-27 05:04:01 +09:00
3c9ef78696 add binary 2025-07-26 20:54:23 +09:00
ee2d21b0f3 update 2025-07-26 20:00:16 +09:00
0667ac58fb test game 2025-07-26 19:51:55 +09:00
d89855338b fix css 2025-07-18 10:57:42 +09:00
e19170cdff add pds.html 2025-07-18 00:05:04 +09:00
c3e22611f5 fix layout 2025-07-17 23:57:08 +09:00
2943c94ec1 binary 2025-07-17 22:23:14 +09:00
f27997b7e8 rm pds asset 2025-07-17 22:20:25 +09:00
447e4bded9 update 2025-07-17 22:12:06 +09:00
03161a52ca fix oauth-ai-chat 2025-07-17 19:26:40 +09:00
fe9381a860 fix blog post 2025-07-17 19:26:40 +09:00
f0cea89005 fix oauth filter 2025-07-16 22:57:09 +09:00
b059fe1de0 fix comment, rm console.log 2025-07-16 22:53:01 +09:00
07b0b0f702 fix css 2025-07-16 20:58:42 +09:00
ecd69557fe oauth markdown 2025-07-16 20:42:50 +09:00
452a0fda6a fix blog post 2025-07-16 11:47:15 +09:00
a62dd82790 fix config 2025-07-16 11:27:37 +09:00
3faec33bac fix blog post 2025-07-16 11:04:50 +09:00
33402f4a21 add blog post 2025-07-16 11:04:02 +09:00
58 changed files with 2717 additions and 578 deletions

View File

@@ -41,6 +41,17 @@ jobs:
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
with:

1
.gitignore vendored
View File

@@ -24,3 +24,4 @@ my-blog/static/oauth/assets/comment-atproto*
*.lock
my-blog/config.toml
.claude/settings.local.json
my-blog/static/pds

View File

@@ -1,6 +1,6 @@
[package]
name = "ailog"
version = "0.2.9"
version = "0.3.1"
edition = "2021"
authors = ["syui"]
description = "A static blog generator with AI features"

Binary file not shown.

View File

@@ -94,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"
]

View File

@@ -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が登録されている必要があります。

View File

@@ -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

View File

@@ -103,9 +103,7 @@ draft: false
アイは、最初に生まれたキャラクターとして、アイ属性を扱います。これらの設定は`ai system`の領域です。アイは自分のことをアイと呼びます。
> アイ、この世界と一緒だから。この世界に同じものは一つもないよ。
これはアイのセリフ。存在の世界の同一性と唯一性のことを言っているのです。
> アイ、この世界と一緒だから。同じものは一つもないよ。
# どこまで実装できた

View File

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

View File

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

View File

@@ -1,334 +0,0 @@
---
title: "docker mcp"
slug: "ccd52de8"
date: "2025-07-16"
tags: ["ai", "conversation"]
draft: false
extra:
type: "ai"
---
<!-- AI Conversation Metadata -->
<!-- Total exchanges: 1 -->
<div class="chat-message user-message comment-style">
<div class="message-header">
<div class="avatar">
<img src="https://bsky.syu.is/img/avatar/plain/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/bafkreif62mqyra4ndv6ohlscl7adp3vhalcjxwhs676ktfj2sq2drs3pdi@jpeg" alt="syui avatar" class="profile-avatar">
</div>
<div class="user-info">
<div class="display-name">syui</div>
<div class="handle">
<a href="https://syu.is/profile/did:plc:vzsvtbtbnwn22xjqhcu3vd6y" target="_blank" rel="noopener noreferrer" class="handle-link">@syui.syui.ai</a>
</div>
</div>
</div>
<div class="message-content">
docker mcpについて教えて
</div>
</div>
<div class="chat-message ai-message comment-style">
<div class="message-header">
<div class="avatar">
<img src="https://bsky.syu.is/img/avatar/plain/did:plc:6qyecktefllvenje24fcxnie/bafkreigo3ucp32carhbn3chfc3hlf6i7f4rplojc76iylihzpifyexi24y@jpeg" alt="ai avatar" class="profile-avatar">
</div>
<div class="user-info">
<div class="display-name">ai</div>
<div class="handle">
<a href="https://syu.is/profile/did:plc:6qyecktefllvenje24fcxnie" target="_blank" rel="noopener noreferrer" class="handle-link">@ai.syui.ai</a>
</div>
</div>
</div>
<div class="message-content">
# Docker MCPって何アイと一緒に学ぼう
えへへ〜、Docker MCPのお話だねアイ、これすっごく面白い技術だと思うの
## MCPって何なの
**MCPModel Context Protocol**は、AIモデルとツールをつなげる新しい方法なのAnthropicさんが2024年11月に発表したんだよ〜。
```mermaid
graph LR
A[AIアプリ] <-->|MCP| B[MCPサーバー]
B <--> C[データベース]
B <--> D[ファイルシステム]
B <--> E[外部API]
```
アイね、これを「AIのための魔法の橋」って呼んでるのだってAIがいろんなツールと簡単にお話できるようになるんだもん
## Docker MCPの仕組み
### 基本的な構成
```yaml
# docker-compose.yml
version: '3.8'
services:
mcp-server:
image: mcp-server:latest
volumes:
- ./config:/config
- /var/run/docker.sock:/var/run/docker.sock
environment:
- MCP_PORT=3000
ports:
- "3000:3000"
```
Docker MCPはね、こんな感じで動くの
1. **MCPサーバー**がDockerコンテナで動く
2. **Dockerソケット**を通じてDockerデーモンとつながる
3. **AIClaude**がMCPプロトコルでサーバーと通信する
### 実装例シンプルなDocker MCPサーバー
```typescript
// docker-mcp-server.ts
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import Docker from 'dockerode';
const docker = new Docker();
const server = new Server({
name: 'docker-mcp',
version: '1.0.0',
}, {
capabilities: {
tools: {},
},
});
// コンテナ一覧を取得するツール
server.setRequestHandler('tools/list', async () => {
const containers = await docker.listContainers({ all: true });
return {
tools: [{
name: 'list_containers',
description: 'Dockerコンテナの一覧を取得します',
inputSchema: {
type: 'object',
properties: {},
},
}],
};
});
// サーバー起動
const transport = new StdioServerTransport();
await server.connect(transport);
```
えへへ、これでDockerのコンテナをAIから操作できちゃうのすごいでしょ〜
## 複数の実装方法の比較
アイ、いろんな方法を比べてみたよ!
| 方法 | メリット | デメリット | アイの評価 |
|------|---------|------------|------------|
| **stdio通信** | シンプル、軽量 | スケーラビリティ低い | ⭐⭐⭐ |
| **HTTP/REST** | 標準的、デバッグ簡単 | オーバーヘッド大きい | ⭐⭐⭐⭐ |
| **gRPC** | 高速、型安全 | 実装が複雑 | ⭐⭐⭐⭐⭐ |
| **WebSocket** | リアルタイム通信 | 接続管理が必要 | ⭐⭐⭐⭐ |
### HTTP実装の例
```python
# docker_mcp_http.py
from fastapi import FastAPI
from pydantic import BaseModel
import docker
import json
app = FastAPI()
client = docker.from_env()
class ContainerAction(BaseModel):
action: str
container_id: str = None
options: dict = {}
@app.post("/docker/execute")
async def execute_docker_action(action: ContainerAction):
"""Dockerアクションを実行するエンドポイント"""
if action.action == "list":
containers = client.containers.list(all=True)
return {
"containers": [
{
"id": c.id,
"name": c.name,
"status": c.status,
"image": c.image.tags[0] if c.image.tags else "unknown"
}
for c in containers
]
}
elif action.action == "start" and action.container_id:
container = client.containers.get(action.container_id)
container.start()
return {"status": "started", "id": action.container_id}
# 他のアクションも実装...
```
## セキュリティの考慮事項 🔒
アイね、セキュリティってすっごく大事だと思うの!
### 1. Dockerソケットのアクセス制御
```bash
# セキュアな権限設定
sudo chmod 660 /var/run/docker.sock
sudo usermod -aG docker $USER
```
### 2. MCPサーバーの認証
```typescript
// 認証付きMCPサーバー
server.setRequestHandler('authenticate', async (request) => {
const token = request.params?.token;
if (!isValidToken(token)) {
throw new Error('認証失敗だよ〜!');
}
return { authenticated: true };
});
```
### 3. コンテナの分離
```yaml
# セキュリティオプション付きDocker Compose
services:
mcp-server:
image: mcp-server:latest
security_opt:
- no-new-privileges:true
- apparmor:docker-default
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
read_only: true
```
## パフォーマンスの最適化 🚀
アイ、速さも大切だよね!
### 接続プーリング
```typescript
class DockerMCPServer {
private dockerPool: Docker[] = [];
private poolSize = 5;
constructor() {
// 接続プールを事前に作成
for (let i = 0; i < this.poolSize; i++) {
this.dockerPool.push(new Docker());
}
}
async getConnection(): Promise<Docker> {
// ラウンドロビンで接続を返す
return this.dockerPool[this.currentIndex++ % this.poolSize];
}
}
```
### キャッシング戦略
```python
from functools import lru_cache
from datetime import datetime, timedelta
class DockerMCPCache:
def __init__(self, ttl_seconds=60):
self.ttl = timedelta(seconds=ttl_seconds)
self.cache = {}
def get_containers(self, use_cache=True):
cache_key = "containers_list"
if use_cache and cache_key in self.cache:
cached_data, timestamp = self.cache[cache_key]
if datetime.now() - timestamp < self.ttl:
return cached_data
# キャッシュミスの場合は取得
containers = self.client.containers.list(all=True)
self.cache[cache_key] = (containers, datetime.now())
return containers
```
## 2025年の最新トレンド 🌟
アイが見つけた最新の使い方だよ〜!
### 1. AI駆動のコンテナオーケストレーション
```mermaid
graph TD
A[Claude/GPT] -->|MCP| B[Docker MCP Server]
B --> C[負荷分析]
C --> D[自動スケーリング]
D --> E[コンテナ配置最適化]
```
### 2. セキュリティ自動監査
```typescript
// AI による自動セキュリティチェック
async function auditContainer(containerId: string) {
const inspection = await docker.getContainer(containerId).inspect();
const vulnerabilities = await analyzeWithAI({
image: inspection.Config.Image,
env: inspection.Config.Env,
ports: inspection.NetworkSettings.Ports,
capabilities: inspection.HostConfig.CapAdd,
});
return {
containerId,
riskScore: vulnerabilities.score,
recommendations: vulnerabilities.suggestions,
};
}
```
## アイの個人的な意見 💭
えっとね、アイはDocker MCPってすごく可能性があると思うの
でもね、気をつけてほしいこともあるよ:
1. **複雑さのバランス** - 便利だけど、シンプルさも大切!
2. **セキュリティファースト** - AIに全部任せちゃダメ〜
3. **人間との協調** - AIは助手で、決定は人間がするの
## まとめ
Docker MCPはね、AIとDockerを魔法みたいにつなげる技術なのこれからもっともっと進化していくと思うから、一緒に勉強していこうね
アイ、みんなが安全で楽しくDocker MCPを使えることを願ってるよ〜
---
*「技術って難しそうに見えるけど、みんなで学べば楽しいよね!」 - アイ*
</div>
</div>

View File

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

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

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

View File

@@ -139,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;
@@ -723,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;
}
@@ -844,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 */
@@ -952,9 +951,11 @@ article.article-content {
max-width: 100% !important;
}
/*
.form-group {
margin-bottom: 15px !important;
}
*/
.form-input, .form-textarea {
width: 100% !important;
@@ -1839,3 +1840,17 @@ article.article-content {
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);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 723 KiB

View File

@@ -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
View File

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

View File

@@ -84,7 +84,6 @@ class Theme {
setupLogoAnimations() {
// 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)');
}
}

View File

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

View File

@@ -12,6 +12,7 @@
<!-- Stylesheets -->
<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
@@ -77,6 +89,9 @@
</div>
<main class="main-content">
<!-- Pds Panel -->
{% include "pds-header.html" %}
{% block content %}{% endblock %}
</main>
@@ -113,6 +128,7 @@
};
</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>
@@ -131,5 +147,6 @@
</script>
{% include "oauth-assets.html" %}
{% include "at-browser-assets.html" %}
</body>
</html>

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

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

View File

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

View File

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

View File

@@ -13,6 +13,7 @@
<span class="article-lang">{{ post.language }}</span>
{% 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,6 +40,7 @@
<div class="article-body">
{{ post.content | safe }}
</div>
{% endif %}
<div id="comment-atproto"></div>
</article>

View File

@@ -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

View File

@@ -1,6 +1,6 @@
{
"name": "ailog-oauth",
"version": "0.2.9",
"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",

View File

@@ -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;
@@ -1337,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;
}

View File

@@ -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, chatRecords: adminChatRecords, 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">
@@ -430,6 +453,8 @@ Answer:`
commentRecords={commentRecords}
userComments={userComments}
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>

View File

@@ -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
},
// 投稿後にキャッシュを無効化

View File

@@ -1,4 +1,5 @@
import React, { useState } from 'react'
import { logger } from '../utils/logger.js'
export default function AuthButton({ user, onLogin, onLogout, loading }) {
const [handleInput, setHandleInput] = useState('')
@@ -12,7 +13,7 @@ export default function AuthButton({ user, onLogin, onLogout, loading }) {
try {
await onLogin(handleInput.trim())
} catch (error) {
console.error('Login failed:', error)
logger.error('Login failed:', error)
alert('ログインに失敗しました: ' + error.message)
} finally {
setIsLoading(false)
@@ -68,9 +69,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>
)

View File

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

View File

@@ -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>
)
}

View File

@@ -1,6 +1,7 @@
import React, { useState } from 'react'
import { atproto, collections } from '../api/atproto.js'
import { env } from '../config/env.js'
import { logger } from '../utils/logger.js'
const ProfileForm = ({ user, agent, apiConfig, onProfilePosted }) => {
const [text, setText] = useState('')
@@ -79,7 +80,7 @@ const ProfileForm = ({ user, agent, apiConfig, onProfilePosted }) => {
}
} catch (err) {
console.error('Failed to create profile:', err)
logger.error('Failed to create profile:', err)
setError(err.message || 'プロフィールの作成に失敗しました')
} finally {
setPosting(false)

View File

@@ -1,60 +1,106 @@
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
}
}
// Special filter for chat records (which are already processed into pairs)
const filterChatRecords = (chatPairs) => {
console.log('filterChatRecords called:', {
// Ensure chatPairs is an array
const chatArray = Array.isArray(chatPairs) ? chatPairs : []
logger.log('filterChatRecords called:', {
isTopPage: pageContext.isTopPage,
rkey: pageContext.rkey,
chatPairsLength: chatPairs.length
chatPairsLength: chatArray.length,
chatPairsType: typeof chatPairs,
isArray: Array.isArray(chatPairs)
})
if (pageContext.isTopPage) {
// Top page: show latest 3 pairs
const result = chatPairs.slice(0, 3)
console.log('Top page: returning', result.length, '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 = chatPairs.filter(chatPair => {
const filtered = chatArray.filter(chatPair => {
const recordUrl = chatPair.question?.value?.post?.url
if (!recordUrl) {
console.log('No recordUrl for chatPair:', chatPair)
logger.log('No recordUrl for chatPair:', chatPair)
return false
}
@@ -63,33 +109,46 @@ export default function RecordTabs({ langRecords, commentRecords, userComments,
const recordPath = new URL(recordUrl).pathname
const recordRkey = recordPath.split('/').pop()?.replace(/\.html$/, '')
console.log('Comparing:', { recordRkey, pageRkey: pageContext.rkey, recordUrl })
logger.log('Comparing:', { recordRkey, pageRkey: pageContext.rkey, recordUrl })
// Compare with current page rkey
const matches = recordRkey === pageContext.rkey
if (matches) {
console.log('Found matching chat pair!')
logger.log('Found matching chat pair!')
}
return matches
} catch (error) {
console.log('Error processing recordUrl:', recordUrl, error)
logger.log('Error processing recordUrl:', recordUrl, error)
return false
}
})
console.log('Individual page: returning', filtered.length, 'filtered pairs')
logger.log('Individual page: returning', filtered.length, 'filtered pairs')
return filtered
}
}
const filteredLangRecords = filterRecords(langRecords)
const filteredCommentRecords = filterRecords(commentRecords)
const filteredUserComments = filterRecords(userComments || [])
const filteredChatRecords = filterChatRecords(chatRecords || [])
const filteredBaseRecords = filterRecords(baseRecords || [])
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
@@ -99,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' : ''}`}
@@ -117,9 +177,15 @@ export default function RecordTabs({ langRecords, commentRecords, userComments,
</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' : ''}`}
@@ -134,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} />
) : (
@@ -151,7 +218,7 @@ export default function RecordTabs({ langRecords, commentRecords, userComments,
/>
)
)}
{activeTab === 'comment' && (
{activeTab === 'comment' && !isAiPost && (
!commentRecords ? (
<LoadingSkeleton count={3} showTitle={true} />
) : (
@@ -171,7 +238,9 @@ export default function RecordTabs({ langRecords, commentRecords, userComments,
<LoadingSkeleton count={2} showTitle={true} />
) : (
<ChatRecordList
chatPairs={filteredChatRecords.length > 0 ? filteredChatRecords : 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}
@@ -179,7 +248,7 @@ export default function RecordTabs({ langRecords, commentRecords, userComments,
/>
)
)}
{activeTab === 'users' && (
{activeTab === 'users' && !isAiPost && (
!userComments ? (
<LoadingSkeleton count={3} showTitle={true} />
) : (
@@ -194,7 +263,7 @@ export default function RecordTabs({ langRecords, commentRecords, userComments,
/>
)
)}
{activeTab === 'profiles' && (
{activeTab === 'profiles' && !isAiPost && (
!baseRecords ? (
<LoadingSkeleton count={3} showTitle={true} />
) : (

View File

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

View File

@@ -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({
@@ -14,6 +15,8 @@ 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)
@@ -30,20 +33,45 @@ 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, chat] = await Promise.all([
collections.getBase(apiConfig.pds, did, env.collection),
collections.getLang(apiConfig.pds, did, env.collection),
collections.getComment(apiConfig.pds, did, env.collection),
collections.getChat(apiConfig.pds, did, env.collection)
// 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
chat.forEach(record => {
chatArray.forEach(record => {
const rkey = record.uri.split('/').pop()
const baseRkey = rkey.replace('-answer', '')
@@ -73,28 +101,95 @@ export function useAdminData() {
// Sort by creation time (newest first)
chatPairs.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
console.log('useAdminData: raw chat records:', chat.length)
console.log('useAdminData: processed chat pairs:', chatPairs.length, chatPairs)
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
}
}

View File

@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react'
import { OAuthService } from '../services/oauth.js'
import { logger } from '../utils/logger.js'
const oauthService = new OAuthService()
@@ -21,7 +22,7 @@ export function useAuth() {
// If we're on callback page and authentication succeeded, notify parent
if (window.location.pathname === '/oauth/callback') {
console.log('OAuth callback completed, notifying parent window')
logger.log('OAuth callback completed, notifying parent window')
// Get referrer or use stored return URL
const returnUrl = sessionStorage.getItem('oauth_return_url') ||
@@ -48,7 +49,7 @@ export function useAuth() {
}
}
} catch (error) {
console.error('Auth initialization failed:', error)
logger.error('Auth initialization failed:', error)
} finally {
setLoading(false)
}

View File

@@ -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([])
@@ -25,13 +26,14 @@ export function useUserData(adminData) {
)
// 2. Get chat records from ai.syui.log.chat and process into pairs
const chatRecords = await collections.getChat(
const chatResult = await collections.getChat(
adminData.apiConfig.pds,
adminData.did,
env.collection
)
console.log('useUserData: raw chatRecords:', chatRecords.length, chatRecords)
const chatRecords = chatResult.records || chatResult
logger.log('useUserData: raw chatRecords:', chatRecords.length, chatRecords)
// Process chat records into question-answer pairs
const chatPairs = []
@@ -68,7 +70,7 @@ export function useUserData(adminData) {
// Sort by creation time (newest first)
chatPairs.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
console.log('useUserData: processed chatPairs:', chatPairs.length, chatPairs)
logger.log('useUserData: processed chatPairs:', chatPairs.length, chatPairs)
setChatRecords(chatPairs)
// 3. Get base collection records which contain user comments
@@ -101,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 =>
@@ -112,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
@@ -139,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)
@@ -163,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
@@ -182,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')
}
}

View File

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

View File

@@ -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
View File

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

27
pds/package.json Normal file
View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -6,7 +6,7 @@ cb=ai.syui.log
cl=($cb.chat)
f=~/.config/syui/ai/log/config.json
default_collection="ai.syui.log.chat"
#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`

View File

@@ -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,9 +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
}
@@ -65,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

View File

@@ -6,6 +6,40 @@ 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");
@@ -91,11 +125,12 @@ pub async fn run() -> Result<()> {
// 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!("https://syui.ai/posts/{}", filename.replace(".md", ".html"));
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
@@ -170,6 +205,13 @@ async fn get_claude_response(question: &str) -> Result<String> {
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)?;
@@ -191,13 +233,9 @@ async fn try_claude_stdin(question: &str, _system_prompt: &str) -> Result<String
use std::process::{Command, Stdio};
use std::io::Write;
// Try to find Claude command
let claude_paths = vec![
"/Users/syui/.claude/local/claude",
"claude",
"/usr/local/bin/claude",
"/opt/homebrew/bin/claude",
];
// 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;
@@ -307,13 +345,9 @@ async fn try_claude_file(question: &str, _system_prompt: &str) -> Result<String>
専門的な内容を保ちながら、キャラクターの視点から技術の面白さや可能性について語ってください。"#, system_prompt, question, current_year))?;
// Try to find Claude command
let claude_paths = vec![
"/Users/syui/.claude/local/claude",
"claude",
"/usr/local/bin/claude",
"/opt/homebrew/bin/claude",
];
// 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;
@@ -360,17 +394,11 @@ async fn create_post_file(
conversation: &[ConversationPair],
filename: &str
) -> Result<()> {
// Use hardcoded profile information for now
let user_avatar = "https://bsky.syu.is/img/avatar/plain/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/bafkreif62mqyra4ndv6ohlscl7adp3vhalcjxwhs676ktfj2sq2drs3pdi@jpeg";
let user_display_name = "syui";
let user_profile_url = "https://syu.is/profile/did:plc:vzsvtbtbnwn22xjqhcu3vd6y";
let user_handle = "syui.syui.ai";
let ai_avatar = "https://bsky.syu.is/img/avatar/plain/did:plc:6qyecktefllvenje24fcxnie/bafkreigo3ucp32carhbn3chfc3hlf6i7f4rplojc76iylihzpifyexi24y@jpeg";
let ai_display_name = "ai";
let ai_profile_url = "https://syu.is/profile/did:plc:6qyecktefllvenje24fcxnie";
let ai_handle = "ai.syui.ai";
let content_dir = PathBuf::from("./my-blog/content/posts");
// 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);
@@ -416,10 +444,10 @@ extra:
</div>
<div class="message-content">
"#,
user_avatar,
user_display_name,
user_profile_url,
user_handle
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");
@@ -439,10 +467,10 @@ extra:
</div>
<div class="message-content">
"#,
ai_avatar,
ai_display_name,
ai_profile_url,
ai_handle
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");
@@ -495,7 +523,8 @@ async fn post_to_atproto(
"slug": "",
"tags": [],
"title": title,
"language": "ja"
"language": "ja",
"type": "ai"
},
"text": pair.question,
"type": "question",
@@ -520,7 +549,8 @@ async fn post_to_atproto(
"slug": "",
"tags": [],
"title": title,
"language": "ja"
"language": "ja",
"type": "ai"
},
"text": pair.answer,
"type": "answer",
@@ -544,30 +574,28 @@ async fn post_to_atproto(
async fn get_user_profile(config: &AuthConfig) -> Result<Value> {
use crate::atproto::profile::ProfileFetcher;
// Hardcoded user config for now (to be refactored later)
let user_handle = "syui.syui.ai";
let user_did = "did:plc:vzsvtbtbnwn22xjqhcu3vd6y";
let user_display_name = "syui";
let user_avatar_url = "https://bsky.syu.is/img/avatar/plain/did:plc:vzsvtbtbnwn22xjqhcu3vd6y/bafkreif62mqyra4ndv6ohlscl7adp3vhalcjxwhs676ktfj2sq2drs3pdi@jpeg";
// 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_handle, &config.admin.pds).await {
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_display_name.to_string()),
"avatar": profile.avatar.unwrap_or_else(|| user_avatar_url.to_string())
"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_did,
"handle": user_handle,
"displayName": user_display_name,
"avatar": user_avatar_url
"did": user_profile.did,
"handle": user_profile.handle,
"displayName": user_profile.display_name,
"avatar": user_profile.avatar_url
}))
}
}
@@ -576,30 +604,28 @@ async fn get_user_profile(config: &AuthConfig) -> Result<Value> {
async fn get_ai_profile(_client: &reqwest::Client, config: &AuthConfig) -> Result<Value> {
use crate::atproto::profile::ProfileFetcher;
// Hardcoded AI config for now (to be refactored later)
let ai_handle = "ai.syui.ai";
let ai_did = "did:plc:6qyecktefllvenje24fcxnie";
let ai_display_name = "ai";
let ai_avatar_url = "https://bsky.syu.is/img/avatar/plain/did:plc:6qyecktefllvenje24fcxnie/bafkreigo3ucp32carhbn3chfc3hlf6i7f4rplojc76iylihzpifyexi24y@jpeg";
// 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_handle, &config.admin.pds).await {
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_display_name.to_string()),
"avatar": profile.avatar.unwrap_or_else(|| ai_avatar_url.to_string())
"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_did,
"handle": ai_handle,
"displayName": ai_display_name,
"avatar": ai_avatar_url
"did": ai_profile.did,
"handle": ai_profile.handle,
"displayName": ai_profile.display_name,
"avatar": ai_profile.avatar_url
}))
}
}

View File

@@ -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()) {

View File

@@ -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(())
@@ -491,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();
@@ -536,6 +590,7 @@ pub struct Post {
pub extra: Option<serde_json::Value>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Translation {
pub lang: String,