3 Commits

Author SHA1 Message Date
ae8c7392b5 fix oauth-ai-chat 2025-07-17 19:18:54 +09:00
67bb6e0028 fix blog post 2025-07-17 17:24:14 +09:00
75cdd241d2 fix blog post 2025-07-17 17:16:57 +09:00
115 changed files with 192 additions and 7396 deletions

View File

@@ -41,24 +41,13 @@ 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:
path: ./bin
key: ailog-bin-${{ runner.os }}-v${{ hashFiles('Cargo.toml') }}
key: ailog-bin-${{ runner.os }}
restore-keys: |
ailog-bin-${{ runner.os }}-v
ailog-bin-${{ runner.os }}
- name: Setup ailog binary
run: |

View File

@@ -48,7 +48,6 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:

1
.gitignore vendored
View File

@@ -24,4 +24,3 @@ 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.3.4"
version = "0.2.9"
edition = "2021"
authors = ["syui"]
description = "A static blog generator with AI features"

Binary file not shown.

View File

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

View File

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

View File

@@ -1,350 +0,0 @@
---
title: "archlinux install by syui"
slug: "arch"
date: "2025-08-08"
tags: ["arch"]
draft: false
---
## 最小構成
まずはdiskの設定から。
```sh
# cfdisk /dev/sda
```
次にdiskのフォーマットなど。それをmountしてarchlinuxを入れます。bootloaderも設定しておきましょう。
```sh
$ mkfs.vfat /dev/sda1
$ mkfs.ext4 /dev/sda2
$ mount /dev/sda2 /mnt
$ mount --mkdir /dev/sda1 /mnt/boot
$ pacstrap /mnt base base-devel linux linux-firmware linux-headers
$ genfstab -U /mnt >> /mnt/etc/fstab
$ arch-chroot /mnt
$ pacman -S dhcpcd grub os-prober efibootmgr
$ grub-install --target=x86_64-efi --efi-directory=/boot --bootloader-id=grub
$ grub-mkconfig -o /boot/grub/grub.cfg
```
これで`exit;reboot`すると起動できます。
## よく使うもの
```sh
$ pacman -S openssh zsh vim git tmux cargo
```
## userの作成
```sh
$ passwd
$ useradd -m -G wheel ${USER}
$ passwd ${USER}
```
```sh
$ HOSTNAME=archlinux
$ echo "$HOSTNAME" > /etc/hostname
```
shellの変更など。
```sh
$ chsh -s /bin/zsh ${USER}
or
$ useradd -m -G wheel -s /bin/zsh ${USER}
```
## sudoの使い方
1. `/etc/sudoers`は編集を間違えると起動できなくなります。安全のため`visudo`が推奨されています。
2. `vim`では`:w!`で保存します。
```sh
$ sudo visudo
or
$ vim /etc/sudoers
```
```sh:/etc/sudoers
%wheel ALL=(ALL:ALL) ALL
```
よく`update`する人は特定のコマンドをpasswordなしで実行できるようにしておいたほうが良いでしょう。
```sh:/etc/sudoers
%wheel ALL=(ALL:ALL) NOPASSWD: /usr/bin/pacman -Syu --noconfirm
```
```sh
$ sudo pacman -Syu --noconfirm
```
## networkの設定
次にnetworkです。ここでは`systemd-networkd`を使用します。`dhcpcd`を使ったほうが簡単ではあります。もし安定しないようなら`dhcpcd`を使用。
```sh
# systemctl enable dhcpcd
```
```sh
$ systemctl enable systemd-networkd
```
network deviceをeth0にします。
```sh
$ ip link
$ ln -s /dev/null /etc/udev/rules.d/80-net-setup-link.rules
```
```sh:/etc/systemd/network/eth.network
[Match]
Name=eth0
[Network]
Address=192.168.1.2/24
Gateway=192.168.1.1
DNS=192.168.1.1
```
```sh
$ systemctl enable systemd-resolved
```
## auto-login
次にauto-loginを設定していきます。ここでは`getty`を使用。`${USER}`のところを自分のusernameにしてください。
```sh
$ mkdir -p /etc/systemd/system/getty@tty1.service.d/
```
```sh:/etc/systemd/system/getty@tty1.service.d/override.conf
[Service]
ExecStart=
ExecStart=-/usr/bin/agetty --autologin ${USER} --noclear %I $TERM
```
```sh
$ systemctl daemon-reload
$ systemctl restart getty@tty1
```
## window-manager
`xorg`でdesktop(window-manager)を作ります。`i3`を使うことにしましょう。`xorg`は`wayland`に乗り換えたほうがいいかも。その場合は`sway`がおすすめ。
```sh
$ pacman -S xorg xorg-xinit i3 xterm
# 確認
$ startx
$ i3
```
```sh:~/.xinitrc
exec i3
```
```sh:~/.bash_profile
if [[ ! $DISPLAY && $XDG_VTNR -eq 1 ]]; then
exec startx
fi
```
## sshの使い方
```sh
$ systemctl enable sshd
$ cat /etc/ssh/sshd_config
Port 22119
PasswordAuthentication no
$ systemctl restart sshd
```
基本的にlanから使う場合はdefaultで問題ありませんが、wanから使う場合は変更します。とはいえ、lanでもport, passwordは変えておいたほうがいいでしょう。
次に接続側でkeyを作ってserverに登録します。
```sh
$ ssh-keygen -f ~/.ssh/archlinux
$ ssh-copy-id -i ~/.ssh/archlinux ${USER}@192.168.1.2 -p 22119
```
`ssh-copy-id`がない場合は以下のようにしましょう。
```sh
$ cat ~/.ssh/archlinux.pub | ssh -p 22119 ${USER}@192.168.1.2 'cat >> ~/.ssh/authorized_keys'
```
この設定で`ssh archlinux`コマンドで接続できます。
```sh:~/.ssh/config
Host archlinux
User syui
Hostname 192.168.1.2
Port 22119
IdentityFile ~/.ssh/archlinux
```
おそらく、これがarchlinuxを普通に使っていくうえでの最小構成かと思います。
serverだけならxorgなどは必要ありません。
## zshの使い方
```sh
$ sudo pacman -S git-zsh-completion powerline zsh-autocomplete zsh-autosuggestions zsh-completions zsh-history-substring-search zsh-syntax-highlighting
```
例えば、`ls -`と入力すると補完され、`C-n`, `C-p`で選択。
```sh:~/.zshrc
alias u="sudo pacman -Syu --noconfirm"
alias zs="vim ~/.zshrc"
alias zr="exec $SHELL && source ~/.zshrc"
source /usr/share/zsh/plugins/zsh-autocomplete/zsh-autocomplete.plugin.zsh
source /usr/share/zsh/plugins/zsh-autosuggestions/zsh-autosuggestions.zsh
source /usr/share/zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh
source /usr/share/zsh/plugins/zsh-history-substring-search/zsh-history-substring-search.zsh
# source /usr/share/powerline/bindings/zsh/powerline.zsh
autoload -Uz compinit
compinit
fpath=(/usr/share/zsh/site-functions $fpath)
HISTSIZE=10000
SAVEHIST=10000
HISTFILE=~/.zsh_history
setopt SHARE_HISTORY
setopt HIST_IGNORE_DUPS
bindkey '^[[A' history-substring-search-up
bindkey '^[[B' history-substring-search-down
```
`powerline`は重いのでコメントしています。
## フリーズの解消
古いpcにlinuxを入れる際は`linux-fm`に注意してください。
頻繁にフリーズするようなら`linux-firmware`を削除するのがおすすめです。
```sh
$ pacman -Q | grep linux-firmware
$ pacman -R linux-firmware ...
# pacman -S broadcom-wl-dkms
```
## pacmanが壊れたときの対処法
```sh
$ pacman -Syu
# これがうまくいかないことがある
```
```sh
# dbがlockされている
$ rm /var/lib/pacman/db.lock
# ファイルが存在すると言われる
$ pacman -Qqn | pacman -S --overwrite "*" -
# pgp-keyをreinstallする
$ pacman -S archlinux-keyring
$ pacman-key --refresh-key
```
## archlinuxの作り方
archlinuxはシンプルなshell scriptと言えるでしょう。なので色々と便利です。ここでは、`img.sh`, `install.sh`を作ります。
### img.sh
ここでは`archlinux.iso`, `archlinux.tar.gz`を生成します。これはarchlinux上で実行してください。
```sh:img.sh
#!/bin/bash
pacman -Syuu --noconfirm git base-devel archiso
git clone https://gitlab.archlinux.org/archlinux/archiso
./archiso/archiso/mkarchiso -v -o ./ ./archiso/configs/releng/
mkdir -p work/x86_64/airootfs/var/lib/machines/arch
pacstrap -c work/x86_64/airootfs/var/lib/machines/arch base
arch-chroot work/x86_64/airootfs/ /bin/sh -c 'pacman-key --init'
arch-chroot work/x86_64/airootfs/ /bin/sh -c 'pacman-key --populate archlinux'
tar -zcvf archlinux.tar.gz -C work/x86_64/airootfs/ .
```
例えば、`pacstrap`で自分の好きなツールを指定すれば、独自のimgを作成でき、`docker`にも使えます。
```sh
$ docker import archlinux.tar.gz archlinux:syui
$ docker run -it archlinux:syui /bin/bash
```
### install.sh
最小構成のinstall scriptです。どこかのurlに置いて、install時にcurlして実行するようにすれば便利です。
```sh
$ curl -sLO arch.example.com/install.sh
$ chmod +x install.sh
$ ./install.sh
```
```sh:install.sh
#!/bin/bash
set -euo pipefail
# 変数定義
DISK="/dev/sda"
HOSTNAME="ai-arch"
USERNAME="ai"
# パーティション作成(自動)
parted $DISK mklabel gpt
parted $DISK mkpart ESP fat32 1MiB 1GiB
parted $DISK set 1 esp on
parted $DISK mkpart primary linux-swap 1GiB 5GiB
parted $DISK mkpart primary ext4 5GiB 100%
# ファイルシステム作成
mkfs.fat -F32 ${DISK}1
mkswap ${DISK}2
mkfs.ext4 ${DISK}3
# マウント
mount ${DISK}3 /mnt
mkdir -p /mnt/boot
mount ${DISK}1 /mnt/boot
swapon ${DISK}2
# インストール
pacstrap -K /mnt base linux linux-firmware base-devel vim networkmanager grub efibootmgr
# 設定
genfstab -U /mnt >> /mnt/etc/fstab
arch-chroot /mnt /bin/bash << EOF
ln -sf /usr/share/zoneinfo/Asia/Tokyo /etc/localtime
hwclock --systohc
echo "ja_JP.UTF-8 UTF-8" >> /etc/locale.gen
locale-gen
echo "LANG=ja_JP.UTF-8" > /etc/locale.conf
echo "$HOSTNAME" > /etc/hostname
grub-install --target=x86_64-efi --efi-directory=/boot --bootloader-id=ARCH
grub-mkconfig -o /boot/grub/grub.cfg
systemctl enable NetworkManager
useradd -m -G wheel $USERNAME
EOF
```

File diff suppressed because it is too large Load Diff

View File

@@ -1,151 +0,0 @@
---
title: "game system v0.4.0"
slug: "game"
date: "2025-08-12"
tags: ["ue"]
draft: false
---
今回は、game systemのupdateをまとめます。
分かりづらいので、game systemは全体で同じversionに統一しています。
まず、大きく分けて3つのシステムをupdateしました。
- yui system: キャラクターのバースト(必殺技)を実装
- at system: ログイン処理とデータ構造の作成
- world system: 場所ごとにBGMを再生するシステムの構築
- world system: 惑星に雪や雨を降らせることに成功
<iframe width="100%" height="415" src="https://www.youtube.com/embed/eXrgaVNCTA4?rel=0&showinfo=0&controls=0" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
## 戦闘シーンの作成
1キャラクターにつき、1スキル、1バースト、1ユニークというのは決まっていました。これは`yui system`の領域。
アイの属性はアイ属性なので、テーマカラーは黄色です。属性自体は`ai system`の領域ですが、現在、関連反応のシステムまでは実装していません。
今回はバーストの作成、ダメージ表記、enemy(敵)の撃破までを実装しました。最初から作り変えたので大変でした。
<iframe src="https://blueprintue.com/render/l7_xvfbp" scrolling="no" allowfullscreen style="width:100%;height:400px"></iframe>
## 音楽システムの実装
これは`world system`の領域で、開発中は`PlayerStart`で各位置に瞬間移動して確認しています。これはアイでなければ設定上無理でした。
具体的には、PlayerStartのtagと音楽を同じ名前で登録します。そして、playerに最も近いものを再生します。効率的でシンプルですが、少し欠陥があるシステムかもしれません。これは、enemy-hpの表示と連動させています。現在、鳴らしているbgmの名前がわかれば表示できるというわけですね。enemy-bossもPlayerStartのtagで同じ名前で置いてあります。
<iframe src="https://blueprintue.com/render/x80534fn" scrolling="no" allowfullscreen style="width:100%;height:400px"></iframe>
原作の設定は、ゲーム開発中も適用されます。アイを動かして空を飛んでいますが、あれはアイだからできるのであって、宇宙空間の移動とかもそうです。
原作の設定を紹介しておきます。
### 原作の設定: アイはなぜ空を飛べるのか
アイはものすごい質量を持ちます。空を飛んでいるというより、地球を持ち上げて、空を飛んでいるように見せている、という感じで飛行しています。
いやいや、それじゃあ、地球はアイに落ちるだけで、空どころか地面に落ちるだろう、と言われそうですが、地球というのは宇宙から見るとすごいスピードで回転、移動しています。
そして、宇宙で星と星がぶつかるときは、決して直接ドカーンと衝突するわけではないのです。
お互いに距離を取りながらぐるぐる回って、やがてぶつかる、そんなイメージ。
質量と質量の間があるわけですね。
アイが瞬間的に自身の質量の一部を現すと、間ができ、対象の星の質量を計算しながら、それを持ち上げて動かすような感じで移動しています。
### 原作の設定: アイはなぜ宇宙空間でも平気なの
それはアイの体の周りには極小の大気の膜があるためだとされています。超重力で圧縮された大気の膜があるため、宇宙空間、その他一切の外的影響をあまり受けません。
アイは常に、自分の星の中にいるのと同じ状態、といえばいいのでしょうか。そんな感じです。
## データ構造の変更
次に、`at system`です。ゲームデータを再構築しました。
ゲームデータは主にsystem情報とuser情報に分けられ、jsonで管理されます。
各パラメータですが、ゲームに必要な値を`cp`として圧縮することにしました。このcpをsystem.jsonあるいはゲーム自体で各キャラクターの設定、つまり、`attack: 10%, hp: 20%, skill: 70%`などで分けられます。これが最もわかりやすく、最も効率的な方法だと考えました。
```json:user.json
{
"character": [
{ "id": 0, "cp": 100 }
]
}
```
```json:system.json
{
"character": [
{ "id": 0, "name": "ai", "ability": "ai" }
]
},
{
"ability": [
{ "id": 0, "name": "ai" }
]
}
```
これをログインシステムに連動させました。
このサイトで`at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.verse.user`を検索してもらえればわかります。
```sh
# ゲームシステム
at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.verse
# aiのアカウント
at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.verse.user/6qyecktefllvenje24fcxnie
# syuiのアカウント
at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.verse.user/vzsvtbtbnwn22xjqhcu3vd6y
```
ちなみに、私のアカウントである`syui.syui.ai`ではアイは使用できません。現在使用できるキャラは`dragon`のみ。
現在、アイを使用できるのは、アイのアカウントのみです。この方針は可能な限り維持されるでしょう。
## 惑星に雨や雪を降らせる
これはなかなか苦労していたのですが、実装できました。
まず、有効にすると宇宙空間でも雨が降ってしまいます。止めると惑星内で雨が降りません。
これを解消するには、player locationと0原点のdistanceから条件をつけ、雲の下、雲の上と定義します。調整が必要。
そして、udsのweather、特に`Apply Weather Changes Above Cloud Layer`が重要で、`Apply Clouds`の値を調整します。
<iframe src="https://blueprintue.com/render/dstkcaia" scrolling="no" allowfullscreen style="width:100%;height:400px"></iframe>
## 実体ある太陽のatmosphere問題
まず、私が使っている実体ある太陽にはatmosphereがついています。
これはフレアなどを設定しています。
しかし、これを地球から見た場合、その大気圏を通すと、非常に見栄えが悪い変なカクカクした光が映り込みます。
この解消も非常に苦労しました。例えば、これを`BP_Sun`としましょう。これは起動時にすべての値を設定します。ゲームプレイ中に値の調整をすることは考えられていません。当然と言えるでしょう。
しかし、私のシステムでは、太陽のatmosphereを調整する必要があります。非常に複雑な設定は、リセットでしか解消できないということになりました。そして、udsに入れている小アクタコンポーネントの太陽は、リセットも容易ではありません。
色々な処理を作り、先程作った地表からの現在地の割り出しを条件に、これをリセットする処理をねじ込みました。
<iframe src="https://blueprintue.com/render/nsqu0hnf" scrolling="no" allowfullscreen style="width:100%;height:400px"></iframe>
## 動画で確認
<iframe width="100%" height="415" src="https://www.youtube.com/embed/H1efWYmIugc?rel=0&showinfo=0&controls=0" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
1. BGMが切り替わる
2. 物理ボックスが反応
3. 敵へのダメージ
4. ボスの撃破
5. 雨が雲の上では止まる
6. ログインでatprotoのアカウントを反映
7. プレイでatprotoの情報を更新

View File

@@ -1,180 +0,0 @@
---
title: "なぜ自作ゲームのsystemを作るのか"
slug: "game"
date: "2025-08-18"
tags: ["ue"]
draft: false
---
現在、自作ゲームを開発しています。
どういうゲームかと一言でいうと現実の反映を目指しています。
現実の反映とは何でしょう。例えばゲームではblueskyのようなsnsのアカウントでログインできます。ゲームの世界は現実に合わせた惑星形式です。キャラクターの属性は現実にある物質です。原子や中性子など。
今回は、なぜ自作ゲームのsystemを作っているのか解説します。
## 一つの青写真
私は`2023-12-04`あたりからunreal engine(ue)を触り始めました。
当時、ゲームでこんなことがやりたいなと思って作った画像があります。
![](https://raw.githubusercontent.com/syui/img/master/other/ue5_ai_20231204_0001.jpg)
https://syui.github.io/blog/post/2023/12/04/ue-vs-unity/
今ではゲーム作りに対する考え方も変わりましたが、上のイメージは頭の中にずっと残っていて、ようやく、イメージ通りの戦闘シーンを作成できました。
<iframe width="100%" height="415" src="https://www.youtube.com/embed/tBsYgqI1uSc?rel=0&showinfo=0" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
- 敵の砲撃は剣で弾き返す事ができます。反射したものが当たると敵もダメージを受けます
- 敵の砲撃が激しすぎるためバースト時の無敵時間を長めに設定しています
### 原作の設定: アイのバースト
アイのバースト、つまり必殺技について解説します。自分で作ったカード、`超新星`というタイトルをモデルにした技ですが、技名自体は`中性子`になります。具体的には周囲の原子から中性子を取り出し、それを一点に集めて放つ技。ようは、中性子星を作り出しそれを飛ばしています。これがアイのバースト、中性子です。このゲームは麻雀要素を入れようと考えていて、それはバーストに適用されます。役が揃うと、超新星に変化するという実装を考えています。
### キャラクターの音声
アイはかなり低い確率でスキルやバースト時に音声が付いています。これをキャラクターの音声システムとしましょう。
キャラクターは性格に応じて音声の発動頻度が異なります。アイは最も音声確率が低いランクに割り当てられています。(コンピュータで作っているため粗が見えても困るので)
## 広い世界と狭い世界
### なぜアイを操作するゲームはつまらないのか
私がゲーム作りを始めた理由はいくつかありますが、もし現実にアイというキャラクターがいたら一体どんな感じになるのだろう。それを体感してみたいと思ったからです。
この思いは面白いゲームを作ることにはまるで寄与しないものでしたが、私はそれを作ることにしました。
ここで、なぜアイを操作するゲームがつまらないのか簡単に説明します。
### 広い世界は面白くない
私は普段、アイを使ってゲームを操作し、世界を飛び回り、作っています。
なぜなら、そのほうが開発に便利なのでそうしています。また都合がいいことに、アイというキャラクターは設定上、そういう事が可能となります。
しかし、先程も述べたように、そのようなゲームは恐ろしくつまらない、ということです。
とすれば、重要なのは小さくても、しっかりしたゲームを作ること。上のようなゲームを作ってはいけないのです。
広い世界、無制限の移動ではなく、狭くてもしっかりした世界を作らなければいけない。面白いゲームとはそういうものです。
### 最初の思いと面白いゲーム
次は、初めての思い、初心を大切にすることを考えていきます。
開発者が作りたいゲームと面白いゲームは大抵の場合、両立しません。
例えば、映像美、見せることと実際に面白いことは違うのです。
誰もが初めて何かをする時、そこには各人の思いがあります。それは小さいものであれ大きいものであれ、そこに意識がある。
それは、時間が経つと忘れてしまうものですが、心の奥深くに残っている。
しかし、大抵の場合、そういった思いと、誰もが面白いと思う人気ゲームを作るという思いは相反します。
つまり、心の奥底に眠っている最初の思いと、面白いゲームは違うものだし、また、ゲームに限らず、これは色んな作品に言えることだと思います。
これを勘違いして「自分の思いは、他人にも面白いはずだ」とそう思い込むのは誤りです。
では、どうすればいいのでしょう。最初の思いを捨て、面白いゲームを分析して世間に受け入れられるものを作るべきなのでしょうか。
これはyesとも言えるし、noとも言えます。
優先順位としては、間違いなく面白いゲームを作るべきです。個人開発者のよくわからないこだわりなどさっさと捨てるべき、そう思います。
ですが、本当にそれでいいのでしょうか。
私はそれはもったいないと思います。
したがって、できる限りそこを両立させる方法を探すべきだと思います。
私はここで`分離`という方法を使います。世界(方針)を切り離すのです。
これが広い世界と狭い世界、個人開発の指針になります。一見矛盾するこの2つの世界の分離と統合を考えます。
最初の思い、本当に作りたかったもの、楽しくも面白くもないけど、自分の世界。そして、小さく作る面白いゲームの世界。
自分で作ったものを無駄にしないようシステムという4つの柱を立てました。
4つの柱は、根源的な価値観によるもの。広いも狭いも、面白いも面白くないも関係ありません。systemは以下のようになります。これらはどのゲームにも当てはめられ、使用できることを目指して設計されます。
- `world system`: 現実に合わせた世界を構築するシステム。最初は地球、太陽、月から生成される。ゲームエンジンの背景に月の絵を動かすという常識を変更する。実体ある月をその空間に置くことで背景を生成。惑星システムを構築する。
- `ai system`: 属性やゲーム性を構築するシステム。
- `yui system`: 唯一性という価値観から構築されるシステム。現実をゲームに、ゲームを現実に反映することで自然と実現される。
- `at system`: プレイヤーのアカウントをatprotoというprotocolの理解により構築する。protocolは容易には無くならないし変更されないもの。`@`というdomainで繋がるユーザーアカウントのシステム。blueskyというsnsに利用されている。
### systemを作ろう
ゲームはsystemの集合体で作るのが一番いい。
開発では、まとめることが重要になります。
systemとは関数やコンポーネント、変数の集まりです。それは結果であって目的ではありません。
目的の一つはわかりやすさ。例えば、敵を倒した時、アイテムをドロップさせる処理を作ります。個別に敵のBPに作るのではなく、systemとして作っておくとよいでしょう。1と押せば1つのアイテムがドロップします。3と押せば3つです。内容もランダムかつランクを付けましょう。Aランク-Cランクのアイテムです。
つまり、そのsystemに3Bと伝えると、3つのアイテムがドロップし、Bランクのアイテムがドロップしやすい、という結果が出力されます。アイテム一覧もすべてそのsystemが管理し、簡単に設定できます。
ゲームを作るというのは、systemを作ること。それは単体の実行ではありません。個人開発の場合は特に設計を重視し、まとめることを重視します。
これが何かというと、一つはルールを作ることです。
1キャラクターにつき1スキル、1バースト。例外は認めない。このようなルールです。そして、そのルールに基づいたsystemを設計し、例えば、キャラクターならキャラクターの統一管理を目指します。統一管理というのは、数字やobjectを入れれば設定は完了です。その通りにすべてのキャラクターが共通動作するようにしておこう、そこを目指そうということです。
これがsystemを作るということです。
私は、敵(enemy)を作っている時間がありませんから、enemyもcharacterにまとめることにしました。
そのため、少し動きの調整が難しかったりもしますが、この方向性で間違いないと思います。
シーンやムービー、ストーリーは広い世界(アイで操作する世界)にまとめ、enemyもplayerが操作できるcharacterにまとめ、単体で作るものを減らし、すでにあるもの、作ったものは他の役割も担えるようにしていきましょう。このような考え方が個人開発では重要になってくると思います。
<iframe width="100%" height="415" src="https://www.youtube.com/embed/L6eZUZNCOH8?rel=0&showinfo=0" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
### やることを明確に
個人開発者には、できることとできないことがあります。
市販のゲームは、あらゆる専門家が大量に集まり一緒に作っているゲームです。個人開発で全部はできません。
しかし、できないからと言って、手を抜くのも違いますよね。
確かに、私が作りたかったゲームは面白くないかもしれない。けど、一見すると面白そうに見えます。
映像美や見せることと実際に面白いことは違います。しかし、映像美は別に悪いことではありません。それはゲームとしては面白くないけど、見せる力があると思います。シーンやムービーとして利用できるのではないでしょうか。
私はシーンやムービーを作っている時間はありません。しかし、私が今まで作ってきた広い世界はそういったことに使えばいい。狭い世界の背景にも使えます。
いつか行けるかもしれない広い世界。理想と現実。
最初から理想だけあっても、それは面白くない。最初から現実だけあってもそれはただのゲームです。面白いゲームとは、現実と理想のバランス。あるいは、その過程を作ることにあるのではないでしょうか。
最初の夢を持ち続けることも、そして、多くの人が楽しめるものを作る事も両方大切です。初心を捨てず分離して、新たに面白いゲームを目指して作り始めること。そして、最終的に統合できる道筋を思い描けるなら。そんなことを思います。
次は、小さくも完璧で、狭くても全てに由来がある、そんな世界を作っていこうと思っています。
### 狭い世界をどうやって作っていこう
ポイントは、カラフル+ポップだと思います。小さい+完璧もポイントですね。また、動作は非常にゆっくりがいいのではないでしょうか。
つまり、小さいが完璧に動作し、カラフルでポップな世界。それがここでいう狭い世界になります。
私はこの辺のこともあまり知りませんから、epicの[stack o bot](https://www.fab.com/ja/listings/b4dfff49-0e7d-4c4b-a6c5-8a0315831c9c)というテンプレートをもとに学習しながら作っていこうと考えています。
<iframe width="100%" height="415" src="https://www.youtube.com/embed/6tO0S7IOC9w?rel=0&showinfo=0" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
## game system v0.4.3
- ps5 controllerの対応
- game animation sampleとstack o botの統合
- item drop system
- character audio system
- sword reflection
- character dragon skill (enemy)
- bgm systemの修正

View File

@@ -1,101 +0,0 @@
---
title: "自作ゲームを開始から終了までプレイ"
slug: "game"
date: "2025-08-23"
tags: ["ue"]
draft: false
---
自作ゲームを開発しています。
今回は開始から終了までの大体の流れができたのでプレイしてみました。
<iframe width="100%" height="415" src="https://www.youtube.com/embed/FTX1CrzKBy8?rel=0&showinfo=0" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
## ゲームの流れを解説
1. 物語は宇宙から始まる。プレイヤーの村に突然宇宙船がやってきて、プレイヤーが連れ去られる。
2. ここは船内にある檻の中。監視兵が慌ただしい。「おい、あれが出たって」「まさか」などの会話。チラと窓の外に目をやる。すると何かが光ったような気がした。
3. (ここでプレイヤーは初回のみアイを操作可能になる。ゲーム開始時にすぐに操作可能にすることが重要だと思ったので、シーンの作成はやめて、プレイヤーに戦艦を撃破してもらうことに)
4. 艦内は爆発し、星に落ちていく。目が覚めると...そこからステージが始まる。(ここからプレイヤーのキャラクターに切り替わる。今回はアイのアカウントなので、アイになっているが本来は違う)
5. ステージの背景に小さな子がぐーぐ寝ている。(先ほど操作したアイは変身時の金髪輪っかなので、黒髪に戻っているアイを見てもプレイヤーにはわからない)
6. ステージを進み、ドラゴンを倒してゲームは終了。花火っぽいものを打ち上げ、ポーズを決める。その後、ゲーム終了まで自由操作。アイテムのドロップがある。
## 面白いゲームを目指して
インベーダーやマリオなど今の技術では簡単に作れそうなレトロゲームがあります。
それらが面白いのかと言われれば、私は面白いと思います。
とはいえ、今そういったもので遊ぶかというと、それは違うと思います。
しかし、個人開発者はまずその段階に到達する必要があるのではないかと感じます。
では、レトロゲームの面白さについて、改めて分析してみることにしましょう。
レトロゲームなんて、AIを使えば簡単に作れますよ。そんな声が聞こえてきそうですが、それは少し違うと思います。
例えば、ステージ1を作れても、ステージ2,3,4、そして、ラストのステージまで、市販の初代マリオと同じように作っているのでしょうか。
それができていないなら、それは作れていないということです。
そして、ステージだけがマリオじゃないですよね。私がプレイしたことがあるスーパーマリオワールドはボスを倒すと演出がありました。
そこには、物語があり、花火が打ち上がり、紙吹雪が舞い、主人公がポーズを決めるのです。意外とゲーム自体よりそういったものを含めて面白いゲームなのであって、それが重要なのだと思います。
個人開発者の多くは私を含め、そういった面白いゲームを作れているのかというと、できていないと思うのです。
もちろん、色々な人がいますから、できている人もいると思います。しかし、私にはできていない。そこまで作れていないし、レトロゲームの域にすら到達していません。
## 面白いものと売れるものは違う
では、面白いものを作れば、それで売れるのかというと、それもまた違うと思います。
既にあるゲームのパクリ、それはそれで面白いゲームになると思います。しかし、今更レトロゲームを作っても、誰もプレイしないと思います。
面白いゲームと人気が出ることは違うのです。
そこで重要になるのがオリジナリティという要素ではないでしょうか。
したがって、段階があるとしたら、面白いゲームの域に到達する。その後、オリジナリティの域に到達する。あるいは、同時にそれをこなす必要があるのだと思います。
とはいえ、まずは面白いゲームを作ること。せめてその域に到達したいですよね。そして、レトロゲームにも十分に面白い要素は揃っているので、それらを参考にするのが良いと判断しました。
## 人気が出ることと利益が出ることもまた違う
そういえば、収益化もまた別の話だよなと思ったので書きます。
確かに、それは必須条件かもしれませんが、それがあれば必ずというものではないと思います。確かに寄与する部分は少なからずあるとは思うけど。
例えば、snsをみていると、すごいインプレッション、注目を集めたのに、売れなかった漫画がたくさんあります。つまり、人気は出たが、利益は出なかったケースだと思います。
したがって、収益化までの道のりもまた長いのではないか。大変なのかもしれない。そんなふうに思うのです。
これを短絡的な見通しで「面白ければ売れる」などと考えていると、当てが外れるかもしれない。そのへんはあまり期待しないほうがいいかも。
段階的にそれぞれの戦略を考えていくのが良いのではないかな。
`面白い -> 人気が出る -> 利益が出る`...その間にも高い壁がある。
## オリジナリティはどうやって出すのか
例えば、制約からです。
私は設計において、いくつかの決め事を作りました。例えば、以下のルールがあります。
1. 物理法則に反しない
ゲームで物を浮かせるのは簡単だ。しかし、この世界は現実の反映を目指している。したがって、すぐに物を浮かせたり、あるいはテレポートしたり、それをやってはいけない。そういったものには必ず、原理を説明できなければならない。特殊なアイテムが必要となる。このアイテムをアイの家に3つ置いてあるとしよう。この場合、その3つをマップに置くと、その世界にはもうない。使えない。そのようなルールだ。その世界の重要なアイテムはアイが持っていて、作っている。無限には湧いてこない。制限がある。これはatprotoに保存し、公開しておくのもおもしろいかもしれないな。こういったことがその世界の由来につながるのだと思う。
2. マップは一つ
マップは必ず一つの中で完結させること。世界は一つというルール。たくさんのマップを分けてはいけない。不便でも一つのマップの中だけで世界を作ろう。マップを分けてはいけない。宇宙も地上も一つにすること。シーンやムービーを作るときも同じ。違うマップでそれをやってはいけない。
## 今回のゲーム作りで意識したこと
- game system v0.4.4
1. レトロゲームの面白さを必要最小限で実装
2. オリジナリティを融合(このゲームのテーマである宇宙、そして物語)
3. すべての実装を各システムで動かす

View File

@@ -1,107 +0,0 @@
---
title: "plcにhandle changeを反映する"
slug: "plc"
date: "2025-09-05"
tags: ["atproto"]
draft: false
---
いつまで経ってもbsky.teamのplcにhandle changeが反映されないので色々やってみました。
結論から言うと、`PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX`を使用し、`base58`のrotation-keyを作成後に、indigoにある`goat plc`を使用します。
1. `goat key generate --type secp256k1`で生成されたキーを分析
2. そのキーから正しいmulticodecプレフィックスを抽出
3. PDSのhex keyに同じプレフィックスを適用
```sh
$ go install github.com/bluesky-social/indigo/cmd/goat@latest
```
```sh
$ goat account login -u syui.syui.ai -p $PASS --pds-host https://syu.is
$ goat plc history did:plc:vzsvtbtbnwn22xjqhcu3vd6y
did:key:zQ3shZj81oA4A9CmUQgYUv97nFdd7m5qNaRMyG16XZixytTmQ
$ goat plc update did:plc:vzsvtbtbnwn22xjqhcu3vd6y \
--handle syui.syui.ai \
--pds https://syu.is \
--atproto-key did:key:zQ3shZj81oA4A9CmUQgYUv97nFdd7m5qNaRMyG16XZixytTmQ > plc_operation_syui.json
# もしミスった時は前の操作を無効化して再実行
$ goat plc update did:plc:vzsvtbtbnwn22xjqhcu3vd6y \
--handle syui.syui.ai \
--pds https://syu.is \
--atproto-key did:key:zQ3shZj81oA4A9CmUQgYUv97nFdd7m5qNaRMyG16XZixytTmQ \
--prev "bafyreifomvmymylntowv2mbyvg5i7wgv375757l574gevcs7qbysbqizk4" > plc_operation_syui_nullify.json
```
```sh
source base58_env/bin/activate
python3 -c "
import base58
# 生成されたsecp256k1キーを分析
generated_secp256k1 = '${zXXX...}'
decoded = base58.b58decode(generated_secp256k1[1:]) # 'z'を除く
print('Generated secp256k1 key analysis:')
print(' Total length:', len(decoded))
print(' Full hex:', decoded.hex())
# 32バイトの鍵データを除いたプレフィックスを抽出
if len(decoded) > 32:
prefix = decoded[:-32]
key_data = decoded[-32:]
print(' Prefix hex:', prefix.hex())
print(' Prefix length:', len(prefix))
print(' Key data length:', len(key_data))
pds_rotation_hex = '${PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX}'
pds_rotation_bytes = bytes.fromhex(pds_rotation_hex)
prefixed_rotation_key = prefix + pds_rotation_bytes
multibase_rotation_key = 'z' + base58.b58encode(prefixed_rotation_key).decode()
print('\\nConverted PDS rotation key:')
print(' Multibase:', multibase_rotation_key)
else:
print(' No prefix found, key is raw')
"
deactivate
```
```sh
$ PDS_ROTATION_KEY=zXXX...
$ goat plc sign --plc-signing-key "$PDS_ROTATION_KEY" plc_operation_syui.json > plc_signed_syui.json
$ goat plc submit --did did:plc:vzsvtbtbnwn22xjqhcu3vd6y plc_signed_syui.json
success
$ goat plc history did:plc:vzsvtbtbnwn22xjqhcu3vd6y
```
## 手順をおさらい
1. `plc_operation.json`を作成
2. `plc_operation.json``PDS_ROTATION_KEY`を使用し、`plc_signed.json`を作成
3. `plc_signed.json`を使用し、plcを更新
## plcを確認
```sh
did=did:plc:vzsvtbtbnwn22xjqhcu3vd6y
curl -sL "https://plc.directory/$did"|jq .alsoKnownAs
curl -sL "https://plc.syu.is/$did"|jq .alsoKnownAs
[
"at://syui.syui.ai"
]
[
"at://syui.syui.ai"
]
```

View File

@@ -1,31 +0,0 @@
---
title: "ue5でdualsenseを使う"
slug: "ps5-controller"
date: "2025-09-07"
tags: ["ue"]
draft: false
---
ps5-controllerは`dualsense`というらしい。ue5で使うには、以下のpluginを使います。fabかgithubのreleaseからpluginフォルダに入れてbuildするか2つの方法があります。
## dualsense plugin
- [https://github.com/rafaelvaloto/WindowsDualsenseUnreal](https://github.com/rafaelvaloto/WindowsDualsenseUnreal)
- [https://github.com/rafaelvaloto/GamepadCoOp](https://github.com/rafaelvaloto/GamepadCoOp)
![](/img/ue_ps5_controller_v0100.jpg)
`v1.2.10`からmultiplayを意識した`GamepadCoOp`との統合が行われました。
コントローラーのライトをキャラクター切り替え時に変更する処理を入れました。
<iframe src="https://blueprintue.com/render/tx_q1evf" scrolling="no" allowfullscreen style="width:100%;height:400px"></iframe>
## dualsenseの分解
最近、ドリフト問題が発生していたこともあり、何度も分解していました。
よって、このタイプのコントローラーなら簡単に修理できるようになりました。
今後も`dualsense`を使用していく可能性は高いですね。

View File

@@ -1,124 +0,0 @@
---
title: "comfyuiでwan2.2を試す"
slug: "comfyui"
date: "2025-09-10"
tags: ["comfyui"]
draft: false
---
comfyuiにwan2.2が来ていたので試してみました。wanがcomfyuiの公式に採用されているので、導入が簡単になっています。
今回は爆速になったLoRA採用でいきます。なお、無効化ードを外すとクオリティ重視の設定になります。
関係ありませんが、comfyui公式ページのコメントシステムは[giscus/giscus](https://github.com/giscus/giscus)を使用しているようですね。
# comfyui
```sh
$ git clone https://github.com/comfyanonymous/ComfyUI
$ cd ComfyUI
$ winget install python.python.3.13
$ pip uninstall torch torchaudio
$ pip install --pre torch torchvision torchaudio --index-url https://download.pytorch.org/whl/nightly/cu129
$ pip install -r requirements.txt
$ python main.py
```
もしvenvを使用する場合
```sh
$ python -m venv venv
$ venv\Scripts\activate
$ pip install -r requirements.txt
$ python main.py
```
## wan2.2
基本的にpromptから生成し、bypassを切ると画像を参照できます。
workflowをdownloadしてcomfyuiで開きます。
```sh
# ComfyUI/user/default/workflows/
$ curl -sLO https://raw.githubusercontent.com/Comfy-Org/workflow_templates/refs/heads/main/templates/video_wan2_2_5B_ti2v.json
```
必要なものは公式ページにリンクがあります。
[https://docs.comfy.org/tutorials/video/wan/wan2_2](https://docs.comfy.org/tutorials/video/wan/wan2_2)
```sh
ComfyUI/
├───📂 models/
│ ├───📂 diffusion_models/
│ │ └───wan2.2_ti2v_5B_fp16.safetensors
│ ├───📂 text_encoders/
│ │ └─── umt5_xxl_fp8_e4m3fn_scaled.safetensors
│ └───📂 vae/
│ └── wan2.2_vae.safetensors
```
![](/img/comfyui_wan22_0001.png)
<video src="/img/comfyui_wan22_0001.mp4" width="100%" controls></video>
## wan2-2-fun-control
これはポーズを動画から作成して動画を作ります。
```sh
$ curl -sLO https://raw.githubusercontent.com/Comfy-Org/workflow_templates/refs/heads/main/templates/video_wan2_2_14B_fun_control.json
```
[https://docs.comfy.org/tutorials/video/wan/wan2-2-fun-control](https://docs.comfy.org/tutorials/video/wan/wan2-2-fun-control)
```sh
ComfyUI/
├───📂 models/
│ ├───📂 diffusion_models/
│ │ ├─── wan2.2_fun_control_low_noise_14B_fp8_scaled.safetensors
│ │ └─── wan2.2_fun_control_high_noise_14B_fp8_scaled.safetensors
│ ├───📂 loras/
│ │ ├─── wan2.2_i2v_lightx2v_4steps_lora_v1_high_noise.safetensors
│ │ └─── wan2.2_i2v_lightx2v_4steps_lora_v1_low_noise.safetensors
│ ├───📂 text_encoders/
│ │ └─── umt5_xxl_fp8_e4m3fn_scaled.safetensors
│ └───📂 vae/
│ └── wan_2.1_vae.safetensors
```
## wan2-2-fun-inp
これは画像から画像を参考にして動画を生成します。
[https://docs.comfy.org/tutorials/video/wan/wan2-2-fun-inp](https://docs.comfy.org/tutorials/video/wan/wan2-2-fun-inp)
```sh
$ curl -sLO https://raw.githubusercontent.com/Comfy-Org/workflow_templates/refs/heads/main/templates/video_wan2_2_14B_fun_inpaint.json
```
```sh
ComfyUI/
├───📂 models/
│ ├───📂 diffusion_models/
│ │ ├─── wan2.2_fun_inpaint_high_noise_14B_fp8_scaled.safetensors
│ │ └─── wan2.2_fun_inpaint_low_noise_14B_fp8_scaled.safetensors
│ ├───📂 loras/
│ │ ├─── wan2.2_i2v_lightx2v_4steps_lora_v1_high_noise.safetensors
│ │ └─── wan2.2_i2v_lightx2v_4steps_lora_v1_low_noise.safetensors
│ ├───📂 text_encoders/
│ │ └─── umt5_xxl_fp8_e4m3fn_scaled.safetensors
│ └───📂 vae/
│ └── wan_2.1_vae.safetensors
```
## ゲームで動かしたほうがいい
今回、ゲームのスクショを使って動画を生成してみました。
しかし、ゲームで動かしたほうがよほど早く確実です。

View File

@@ -1,64 +0,0 @@
---
title: "comfyuiでフィギュア化する"
slug: "comfyui"
date: "2025-09-11"
tags: ["comfyui"]
draft: false
---
`gemini`でnano bananaというフィギュア化が流行っています。今回は、それを`comfyui`で再現してみようと思います。
# comfyui
```sh
$ git clone https://github.com/comfyanonymous/ComfyUI
$ cd ComfyUI
$ winget install python.python.3.13
$ pip uninstall torch torchaudio
$ pip install --pre torch torchvision torchaudio --index-url https://download.pytorch.org/whl/nightly/cu129
$ pip install -r requirements.txt
$ python main.py
```
もしvenvを使用する場合
```sh
$ python -m venv venv
$ venv\Scripts\activate
$ pip install -r requirements.txt
$ python main.py
```
## flux-1-kontext-dev
[https://docs.comfy.org/tutorials/flux/flux-1-kontext-dev](https://docs.comfy.org/tutorials/flux/flux-1-kontext-dev)
基本的にcomfyuiで作成した画像にはworkflowが保存されています。ですから、画像があればpromptやbypassなども含めて生成情報がわかります。情報が削除されていない限りは再現することが可能です。
今回は、`flux-1-kontext-dev`を使用します。
```sh
$ curl -sLO https://raw.githubusercontent.com/Comfy-Org/example_workflows/main/flux/kontext/dev/flux_1_kontext_dev_basic.png
```
```sh
📂 ComfyUI/
├── 📂 models/
│ ├── 📂 diffusion_models/
│ │ └── flux1-dev-kontext_fp8_scaled.safetensors
│ ├── 📂 vae/
│ │ └── ae.safetensor
│ └── 📂 text_encoders/
│ ├── clip_l.safetensors
│ └── t5xxl_fp16.safetensors or t5xxl_fp8_e4m3fn_scaled.safetensors
```
[msg content="prompt: Convert to collectible figure style: detailed sculpting, premium paint job, professional product photography, studio lighting, pristine condition, commercial quality, toy photography aesthetic"]
以下は`wan2.1`で生成した時の動画。
![](/img/comfyui_wan21_0001.webp)
![](/img/comfyui_flex1_nano_banana_0001.png)
できました。

View File

@@ -1,110 +0,0 @@
---
title: "blenderで作ったモデルを改良した"
slug: "ue-blender-model"
date: "2025-09-18"
tags: ["ue", "blender"]
draft: false
---
blenderで作ったモデルは、ueで動かしてみると、なかなか思ったとおりに動かないことは多いです。原因も多種多様で、とても一言では言い表せない。
今まで気になっていたところは以下の2点でした。
1. 指がちゃんと動かない
2. 衣装のすり抜けが気になる
## 指を修正するにはueからblenderへ
blenderで作ったモデルは指がぎこちない動きで、複数の要因が関係しています。特に大きいのが手動で塗っていたウェイトペイント。
しかし、これを完璧に塗り、かつueで動作確認するのはよくありません。なぜなら、blenderとueで動きが異なるからです。それも全く異なるわけではなく微妙に合わないのです。
ということで、ueでまず指の動きがちゃんとできているモデルをblenderに持ってきて、手の部分を移植するというのが今回採用した方法です。
- o: `ue -> blender`
- x: `blender -> ue`
![](/img/ue_blender_model_ai_v0701.png)
![](/img/ue_blender_model_ai_v0702.png)
動きを見るのは、vrm4uの`RTG_UEFN_${name}`を使用します。
blenderの操作方法です。ポイントだけ書いておきます。
1. modelを2つ読み込む。aとbとする。
2. bのboneとbody(object)でいらない部分を削除する。ここでは手の部分だけ残す。 key[x]
3. a, bで大体の位置を合わせる。 key[g, z]
4. bのboneを選択肢、aのboneを選択して統合する。 key[C-j]
5. サイドバーのアーマチュアのところをみて、手のボーンを腕のボーンにいれる(これはモデルによる)。特に手がオフセット維持で指についていることが重要。 key[C-p]
6. bのbody(object)を選択し、モディファイアからaのbodyにデータ転送する。データ転送では、頂点データ、頂点グループを選択。適用する。
7. bのbodyを選択し、aのbodyを選択して統合する。 key[C-j]
8. bodyを編集して、手と腕をつなげる。
あとは、vrm exportの際に指とかのボーンを自動で入れれば動くと思います。
私の場合は、スカートに必要なボーンを追加したりもしました。これはueでの動作を意識しましょう。
## スカートと足の動きを関連付ける
衣装は、`Spine`以下にあるワンピースなので、厳密にはスカートではありませんが、ここではスカートということにします。
このスカートは、3d-modelでは非常に厄介なもので、足の動きに追従できず体に入り込んでしまうのです。
これを解消するためには様々な方法があり、たとえblenderの機能を使って解消しても、ueでは効果がありません。よって、こちらもueから解消するのがベストです。
今回、ABPに`Look At`を使うことで解消しました。
```md
# ABP
## Look At
- Bone to Modify: スカート前、中央
- Look at Target: Spine (中心)
## Look At
- Bone to Modify: スカート前、左
- Look at Target: LeftLeg (左足)
## Look At
- Bone to Modify: スカート前、右
- Look at Target: RightLeg (右足)
```
`Look at Location`の位置は調整してください。私の場合は`0, 50, 0`です。
<iframe width="100%" height="415" src="https://www.youtube.com/embed/3o98Aivn--0?rel=0&showinfo=0&controls=0" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
完璧ではないけど、これでもかなり改良されたほう。
## 実践投入
### unique skillのデザインを考える
まず、アイのunique skill(ユニークスキル)のデザインを考えました。
1. カメラワークは正面に移動
2. スロー再生を開始
3. 忍術のようなモーション
4. カメラを通常に戻す
5. 属性の範囲ダメージ
### tatoolsを使って忍術モーションを作る
[tatools](https://www.fab.com/ja/listings/a5d3b60d-b886-4564-bf6d-15d46a8d27fe)を使います。
[https://github.com/threepeatgames/ThreepeatAnimTools](https://github.com/threepeatgames/ThreepeatAnimTools)
使い方は簡単ですが、動画が分かりづらいので、ポイントだけ解説します。pluginの起動、既存のアニメーションの修正、保存です。
1. pluginの起動は、`/Engine/Plugins/ThreepeatAnimTools/Picker/ThreepeatAnimTools_CR_Picker`を起動します。アウトライナーにでもウィンドウを追加しましょう。
2. 修正したいアニメーション(アニメシーケンス)を開いて、`シーケンサで編集 -> コントロールリグにベイク -> CR_UEFNMannyTatoolsRig`します。
3. これでlevel(map)上でレベルシーケンスを開けます。
4. ここからが修正ですが、まず、例えば、腕を選択して向きを変えたとしましょう。これだけでは保存されません。もとに戻ってしまいます。ここで、(1)シーケンサの下にあるアニメーションを削除し、(2)選択している部位のすべてのコンマを削除します。再生してみると編集したとおりになります。
5. 保存は、シーケンサのメニューバーにある保存ボタン(現在のシーケンスとサブシーケンスを保存)を押します。もとのアニメーションを開くと反映されています。
![](https://ue-book.syui.ai/img/0016.png)
### 実戦動画
<iframe width="100%" height="415" src="https://www.youtube.com/embed/tJQ1y-8p1hQ?rel=0&showinfo=0&controls=0" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

View File

@@ -1,401 +0,0 @@
---
title: "ue + vrm4u + mac/ios"
slug: "ue-mac"
date: "2025-09-22"
tags: ["ue", "mac"]
draft: false
---
# ue mac/ios support
- ue 5.6.1
- mac 26
- windows 11
## ue for mac
現時点でのxcode26には対応していません。ueを起動する際はxcodeを切り替えます。そうではないとbuild optionが機能しません。(クエリ中になる)
appleの方針で製品のversionは年号になりました。
> ex: mac26, ios26
```sh
xcode-ue () {
disk=hdd
case $1 in
(u | ue) d=/Volumes/${disk}/Xcode.app/Contents/Developer ;;
(*) d=/Applications/Xcode.app/Contents/Developer ;;
esac
sudo xcode-select --reset
sudo xcode-select --switch $d
}
```
ここでは、`/Volumes/${disk}/Xcode.app`をxocde16とします。
buildすると`/$Project/Mac/${Project}.xcarchive`ができます。
```sh
# Finderでアーカイブを右クリック → "Show in Finder"
# .xcarchive を右クリック → "Show Package Contents"
# ./Products/Applications/ai.app をダブルクリック
```
```sh
# ターミナルでTeam IDを確認
$ security find-identity -v -p codesigning
```
## ue for linux
linux版もbuildすることにしました。
- steam osはたしかlinuxだったはず
- ゲームとpixelstreamingをlinux serverで動かせれば楽
[https://dev.epicgames.com/documentation/ja-jp/unreal-engine/linux-development-requirements-for-unreal-engine](https://dev.epicgames.com/documentation/ja-jp/unreal-engine/linux-development-requirements-for-unreal-engine)
必要なものをdownloadして、windows上で環境を整えます。`clang 18.1.0`
ue editorを開いてメニューの`tool -> c++`で何かを作ります。すると、`.sln`がproject rootにできます。できなければ、`.uproject`を右クリックで`.sln`を作ります。
epic launcherでue installerのoption:linuxを再び有効にします。
`.sln`を開いてbuildに`linux`を選択し、右バーのMyProject(Airse)を右クリックでbuildします。pluginなどが対応していないときは`.uproject`を開いて`false`に変更します。対応している場合もbuild errになることがあります。
`wsl ubuntu`なども必要になるかもしれませんが、情報にはありません。
## vrm4u for mac
まずc++のprojectをueで作成します。
`libassimp.a`を生成します。
[https://github.com/ruyo/assimp](https://github.com/ruyo/assimp)
これを`/Plugins/VRM4U/ThirdParty/assimp/lib/Mac/libassimp.a`に置いて、projectで開きます。pluginがbuildされますが、`/Plugins/VRM4U/Binaries/Mac/*`が生成され、これを使うことになります。
```sh
/Plugins/VRM4U/
├── Binaries
│   ├── Mac
│   │   ├── UnrealEditor-VRM4U.dylib
│   │   ├── UnrealEditor-VRM4UCapture.dylib
│   │   ├── UnrealEditor-VRM4UCaptureEditor.dylib
│   │   ├── UnrealEditor-VRM4UEditor.dylib
│   │   ├── UnrealEditor-VRM4UImporter.dylib
│   │   ├── UnrealEditor-VRM4ULoader.dylib
│   │   ├── UnrealEditor-VRM4UMisc.dylib
│   │   ├── UnrealEditor-VRM4URender.dylib
│   │   └── UnrealEditor.modules
├── ThirdParty
│   ├── assimp
│   │   ├── lib
│   │   │   ├── Mac/libassimp.a #このファイル
└── VRM4U.uplugin
```
他のprojectで利用する際は`/Binaries`, `/ThirdParty`をcopyすればいいので、取っておいてください。vrm4uが更新されたときは再びprojectでbuildしたほうがいいですね。
## ue for git
mac/iosでもbuildできるようにすること、そういったprojectを作成することを目指します。
最終的にiosでもプレイできるゲームを作りたいなと思っていて、これは単純なカードを集め、キャラを強化するゲームにしようと考えています。
mac/iosは軽量パッケージとして必要最小限の構成で作る予定です。特に重いworld systemを分離、統合が簡単にできるようにする予定です。
そこで、winにはrsyncがありませんので、gitを使用することにしました。
```sh
$ winget install git.git
$ cd ${project}
$ git pull
```
署名も機能させておきたいので、gpgを使います。commit, pushすると`verify`が付くやつです。
```sh
$ winget install gnupg.gnupg
$ which gpg
$ git config --global gpg.program "C:\Program Files (x86)\gnupg\bin\gpg.exe"
```
現在使っているterminalは作成(パスフレーズ)が動作しないようです。したがって、mac, linuxで作成してimportします。
```sh
$ gpg --full-generate-key
XXX
$ id=XXX
$ gpg --export-secret-keys ${id} > ~/gpg-key-win.asc
---
$ gpg --import C:\Users\${USER}\gpg-key-win.asc
$ rm C:\Users\${USER}\gpg-key-win.asc
$ gpg --edit-key ${id}
trust
5
quit
```
作成したkeyはwinでimportした後はmac, linuxから削除したほうがいいかも。コマンドは書きません。
これをgit-serverに登録しておけばいいでしょう。
```sh
$ gpg --armor --export ${id}
```
あと、`~/.gitconfig`も更新しておきます。
```sh
$ git config --global user.signingkey ${id}
$ git config --global commit.gpgsign true
```
```sh
# 再起動
$ gpgconf --kill gpg-agent
$ gpg-connect-agent /bye
```
# Airse
ゲームのタイトルは`Airse`に決まりました。まだ決まっていなかったのかというと、決まっていませんでした。仮名で作ってきましたが、これを機に根本的な部分を見直しました。
- ai + verse
- [A]irse = `unrealengine` naming rules
## rse
RSE = [R]elativistic [S]tellar [E]volution
> ja: 相対論的恒星進化
## name rule
- app, name, project = Airse
- repo, dir = `ai/rse`
- id = `ai.syui.rse`
file, variable, function, etc. follow the following name rules.
1. use `_` to separate characters after abbreviations such as `CBP`.
2. use `_` to separate characters before numbers.
3. use capital letters for all other names, priority: `ue > repo`
ex: `CBP_CharacterAiSkill_1`
## game system
`[ai, yui, at, world]`
- AUTHOR = Syui
- PROJECT = Airse
```sh
# example
/Content/${AUTHOR}/${PROJECT}/
├── World/ # world system
│ ├── Origin/ # origin system (dream system)
│ └── BGM/ # bgm system
├── Yui/ # yui system
│ ├── Character/ # character system
│ ├── Enemy/ # enemy system
│ ├── Evolution/ # e system (evo system)
│ ├── Voice/ # voice system
│ └── Live/ # v system (live system)
├── AI/ # ai system
│ ├── Action/ # action system
│ └── Status/ # status system
└── AT/ # at system
├── Item/ # item system
├── Card/ # card system
└── Save/ # save system
```
## bad ex
1. `ai/airse` = `[ai] x 2`
2. `syui/ai/rse` = `priority < ue`
```md
[fix]
1. ai/airse -> ai/rse
2. syui/ai/rse -> Syui/Airse
```
## version
今までgame systemのversionでやってきました。game systemのversionはアイのモデルの変化で決定されてきました。
しかし、ue versionがわかったほうがいいので、以下の形式に変更します。
- version: `5.7.0.6.11`
```sh
[ue] [system] [fix]
5.7.0.6.11
{
"version": "5.7.0.6.11",
{
"ue": "5.7.0",
"system": "6",
"fix": "11"
}
}
```
# 開発の方向性
考えに変化があったので、お伝えします。大きなものは以下の2つです。
1. ゲームの完成を目指すが、ちゃんとしたシステムを作ることも目指す
2. 完璧に自信があるものでゲームを作る
## ちゃんとしたシステムを作る
ゲームの完成を目指して、色々と考えやってきましたが、ちゃんとしたシステムを作ることを優先したほうがいいと考えるようになりました。
というのも、ちゃんとしたシステムを作っておけば、それを組み合わせるだけでいろんなゲームを作れるからです。
ゲームを構成する要素、その基本というのは決まっていて、システムも決まっています。例えば、キャラクター操作。それさえ本当にレベルの高いシステムを自分で作れるなら、色々なゲームに応用できますよね。
ゲームを完成させられることは素晴らしいことです。
しかし、ゲーム制作をやめてしまうときはどんなときでしょう。ゲームを完成させるのは本当に大変で、そこに到達できる人は少ないのですが、しかし、到達できた人も、そこでやめてしまう人が多いんじゃないでしょうか。
続けられる人はごく僅かで、大きな理由は2つあると思っています。
一つは成功しなかったこと。続けるだけのメリットを感じられなかったことだと思います。
2つ目は、作ってきたものがどうしようもなく再利用できない状態にあることだと思います。例えば、作ってきたものがシステム化されておらず、他のゲームを作ろうとしたときに利用できない形、ごちゃごちゃで自分にも把握できず、使い回せない、あるいは別のゲームに統合できない状態であること。
そうでなければ、他のアイデアをすぐに試してみようとなりやすい、ゲーム開発のハードルは低くなっているはずです。にも関わらず、ちょっとやってすぐ辞めてしまう人がいます。
それはなぜかというと、作ってきたものがゴチャゴチャで使い物にならなかったときじゃないかと思います。もちろん、本人の熱意とか継続性とか意思とかそういったものもあります。でも、心が折れそうな時にもコントロール可能な環境要因があるはずで、そうした環境を構築する能力も重要なのではないかと思うのです。周りにちゃんとしたシステムがたくさんある、そんな状態を作る、ということ。そういうのを目指していきたいと思い、ちゃんとしたシステムを作ることも優先的に考え始めました。
## 自信があるものでゲームを構築する
インディーズゲーム、特に3dは本当に難しい。私のゲーム開発の方針は、少しずつ決まってきて、注意しなければならないこともわかってきました。
それは、無理をしないこと。無茶をしないと言い換えてもいいでしょう。
しかし、この無茶をしないというのは表現が難しく、本人が無茶だと思っていなくても無茶に含まれることは多いと思います。
例えば、個人が3dでゲームを作ることでしょう。
「いやいや、そんな」と思われるかもしれませんが、細かいところを見ると、個人開発で3dをやるのは、結構な無茶だと今では思います。また、ueを使うこともそれに含まれるかもしれません。
ueや3dを使うと、個人でも大きいものが作れた気になってしまう。広いマップ、リアルな描写、動く3dモデル。
しかし、扱いきれない武器ほど怖いものはありません。初心者の個人開発にとって、それを置いていくほうがいい場合もある。だけどそれを手にとって進んでしまうのです。
この場合、個人ができるのは、その武器に圧倒的な制限をつけ、使える場面を限定することだと思います。
私も自分のゲームをプレイしていて、この部分はよくできているなというところが少しあります。しかし、総合的なゲーム性で考えると、全然ダメですね。
でも、それなら本当によくできた自信がある部分だけでゲームを作ればいいんじゃないかな。
そこには工夫が必要になるかもしれないし、コンセプトが重要になると思いますが、私はそのような結論に至ります。
完璧に動作する部分、バグが少なく、自信があるところ、自分のゲームの最大の魅力、そこだけを使ってゲームを構築することを今は考えています。
面白いかどうかは、正直わかりませんが、パッと見で、少しプレイして、「あれ、これすごいんじゃない」と思わせることができたら成功だと思います。最初はそこを目指していこうかなと。
無理をしてできることを増やしても意味がありません。特に個人開発で、3dで、かつueだと、それはとても危険な気がします。
とはいえ、重要なのは、たくさん作ること。
3dで開発するな、ueを使うな、開発者は好きなものを作るな、と言いたいわけではありません。
言いたいのは、たくさん作ってきたものの中には光るもの、よくできたものがいくつか出てきます。そういったものを使ってゲームを構築する。その方向性でも考えてみる、ということ。
おそらく、3dで作る場合、ueを使う場合、個人開発者が好きなものを作る場合に、このような制限は役に立つと思います。
以上が、最近の個人開発の方向性、あるいは考え方の話です。
## ちゃんとしたシステムをどのように作るのか
1. 名前規則に忠実であること。フォルダ、ファイルや変数、関数などのすべて。例外的な名前規則を付けたものを置く場所を決めておくこと。
2. 簡単にシステムを分離、統合できる状態であること。
3. 依存関係を減らし、ファイルは自分のフォルダのみで動く状態にすること。assetはdownloadするが、できる限りそれを使わず、使用するものは自分のフォルダに置いて整理すること。それを使ってシステムを構築すること。
4. その時に使わないものは即座に削除すること。これはノードや変数、関数、すべて。
ようは、売られているassetやpluginの状態を目指すのが一番良くて、downloadすればすぐに使えるような形が理想的。
## pvをゲームにする試み
自信があるものでゲームを構築するといっても、どのようにやればいいのでしょう。
ここからは自分が書いたメモを貼り付けます。
原神のキャラクターpvがある。とても参考になりそうだ。
[https://www.youtube.com/watch?v=0MiIciljaWY](https://www.youtube.com/watch?v=0MiIciljaWY)
pvを作ったほうがいいのかと思ったことがあって、いや、pvをゲームにすればいいという案が浮かんだ。
これはpixelstreamingなどで配信することを考えたゲームといえばいいかな。要は簡単に遊べる一つの軽量パッケージのようなものだ。体験版みたいな感じだろうか。
さて、ゲームを開始すると同時にロゴ、音楽が流れる。pvのような少しのムービーがあり、キャラクターが紹介される。
次に、戦闘シーンだが、これに関しては例えばユーザーがスキル、バーストの2種類の技を発動できるようにしておき、ユーザーの行動によってpvが変わるという仕様である。仕様というか、仕組みである。つまり、pvをゲームに、ゲームをpvに。そんな感じの試み。
軽量パッケージ化をどのように進めるか、あるいは、簡単なゲーム作りはどう進めるかに迷っていたが、この方向性で行こうと思う。
pvを作ると同時に、ゲームを作る。ゲームをpvにする。単純だが効果的なアイディアだと思う。
これなら操作可能範囲も大幅に削れるし、基本的にpvを作って、一部操作可能なゲームにするだけだ。
ゲーム性とダメージ変動。これについては簡易的な個人アカウントみたいなものを使って実装したいという案がある。ゲームには再生ボタンがあり、実行するとpvが流れる仕組みだが、上にログインボタンがあり、そこでログインできる。ログインといってもhandleを入れるだけの簡単なものだ。で、oauthで簡単なゲームアカウント作成のページを別に作っておく。それを実行していると、atprotoからデータを読み取る。
ゲーム作りの方向性として、軽量で、しっかりと動き(バグがなく)、効果的で面白いものを作ろうとしていて、その答えの一つが、pvをゲーム化するという案だった。
これはいくつの前提思考をもとに構築されている。前提思考とは、シンプルなゲームを作ろうという試みで、最初はわかりやすいシューティングゲームのようなものを構想していた。
しかし、この構想には不足がある。一言でゲームの始まりは?終わりは?クリアやゲームオーバーの演出は?その他諸々のゲームにとって重要な要素が伝わらない、そして、その部分をどう構築していくか見えない点にあった。
この弱点を克服した案が「ゲームキャラのpv動画をゲームにしよう」というものだ。
もっとシンプルに言うと「pvをゲームにする」ということ。これならイメージがはっきりと浮かび上がり、かつゲーム化する際の要素も決まってくる。ゲームの始まりと終わり、ゲーム中がどのようなものかをはっきりとイメージすることができる。
では、実際のキャラpvを分析してみよう。ここでは原神のウェンティの動画を分析する。
1. 黒い画面にロゴ(作者)が浮かび上がる。美しいBGMが流れる
2. 緑の木々と青い空
3. カメラがキャラクターの方に移動
4. キャラクターが物語の重要なセリフ
5. 背後に敵の影
6. 敵が攻撃してくる、攻撃はキャラクターの方向に向かう、カメラワーク
7. キャラは優雅にそれを避ける
8. キャラの紹介文や演出が入る
9. バトルシーン
10. スキル、爆発の紹介(セリフあり)
11. 最後のキャラを正面に通常攻撃、弓が放たれ
12. ロゴ(タイトル)が浮かび上がる。美しいBGMの終わりと合わせる
これを自分のゲームに当てはめてみる。
1. 黒い画面にロゴ(syui)が浮かび上がる。BGM、ピッチは0.2
2. アイの家とアイが屋根の上に座っている、モーションあり
3. セリフ、物語は天空に浮かぶ島からはじまる(誰しもが興味を掻き立てられる内容=天空城や古代兵器)、BGMのピッチを徐々に上げていくmax:1.0
4. 地球に近づく黒い影
5. 砲撃が始まる、赤い光が星をめがけて落ちていく
6. アイが高速飛行して、敵の場所に移動
7. キャラの紹介と音声
8. スキルとバーストの技紹介、キーやボタンをかっこよく表示(ななめ、大きめ)。背景は灰色とカラーをあわせる
9. バトルシーン(プレイヤーが操作可能)
10. 1ボタン(1操作)でゲームクリア。時間経過で次のシーンに移行
11. 最後にキャラを正面にアップし通常攻撃
12. ロゴ(ai)が浮かび上がり、BGMの終わりと合わせる
面白さの実装には弱いpv。これをどう克服していくかを考える。
- バトルシーンを少し長くする
- atprotoのデータを参照し、現実アカウントの値をダメージ表記に反映する
- ダメージ総合値を表示したり記録したりする
原神の面白さは元素反応にある。つまり、キャラクターの攻撃の組み合わせ。ダメージ増加量など。原神では、キャラを敵の前に動かせる、スキル回し、爆発という流れで戦闘を楽しむ。これを分解すると、「合わせることと、ダメージ増加量のコントロール」だと思う。これを自分にもできる簡単な仕組みで実現できないかを考えている。

View File

@@ -1,111 +0,0 @@
---
title: "ue 5.7.0pでprojectを作り直す"
slug: "ue-57p"
date: "2025-09-25"
tags: ["ue", "mac", "linux"]
draft: false
---
`unreal engine 5.7.0-preview`が来ました。
[https://forums.unrealengine.com/t/unreal-engine-5-7-preview/2658958](https://forums.unrealengine.com/t/unreal-engine-5-7-preview/2658958)
`vrm4u``5.7`に対応しているので、game animation sample(gasp)をベースに構築してみます。
- gasp: [https://www.fab.com/listings/880e319a-a59e-4ed2-b268-b32dac7fa016](https://www.fab.com/listings/880e319a-a59e-4ed2-b268-b32dac7fa016)
- vrm4u: [https://github.com/ruyo/VRM4U/releases/](https://github.com/ruyo/VRM4U/releases/)
## game animation sample for ue5.7
1. まず空のprojectをue5.7で作ります。私は後に利用する`Valiant Combat`で作りました。[a]とします。
2. game animation sampleのproject(gasp)はまだ対応していないので、ue5.6で作ります。[b]とします。
3. [b]の`${project}/Config`, `${project}.uproject`を参考に[a]に移植します。
```json:${project}.uproject
"Plugins": [
{
"Name": "ModelingToolsEditorMode",
"Enabled": true,
"TargetAllowList": [
"Editor"
]
},
{
"Name": "AnimationWarping",
"Enabled": true
},
{
"Name": "RigLogic",
"Enabled": true
},
{
"Name": "LiveLink",
"Enabled": true
},
{
"Name": "LiveLinkControlRig",
"Enabled": true
},
{
"Name": "PoseSearch",
"Enabled": true
},
{
"Name": "AnimationLocomotionLibrary",
"Enabled": true
},
{
"Name": "MotionWarping",
"Enabled": true
},
{
"Name": "HairStrands",
"Enabled": true
},
{
"Name": "Chooser",
"Enabled": true
},
{
"Name": "Mover",
"Enabled": true
},
{
"Name": "NetworkPrediction",
"Enabled": true
}
]
```
特に`../Config/DefaultEngine.ini`が重要です。
これで完了です。普通に動きます。グラフィックがきれいになっているような気がして、軽量感も少し上がってるかも。
![](/img/ue_v570p_gasp_vrm4u_0001.png)
package buildは少し分かりづらいですが、`[]Windows`となっているところを`[o]Windows`にしないといけません。gasp + vrm4uでのpackage buildはwin, macで成功しました。macはxcode26でいけます。
linuxは失敗です。`Microsoft.MakeFile.Targets 44`なので、調べてみると`toolchain: v26_clang-20.x.x`が必要なのでしょう。まだ公開されていないと思います。docsにはlinkがありませんでした。
[https://dev.epicgames.com/documentation/ja-jp/unreal-engine/linux-development-requirements-for-unreal-engine](https://dev.epicgames.com/documentation/ja-jp/unreal-engine/linux-development-requirements-for-unreal-engine)
1. `/Source/`にc++を置いて、`.uproject`を右クリックで`generate visual studio project files`を選択。
2. `${project}.sln`を開きます。
3. Development: Linux
4. 右エクスプローラーから`$project`を選択して、右クリックでbuildを開始。
{{< msg type="warning" content="This version of the Unreal Engine can only be compiled with clang 20.x. clang 18.1.0 may not build it" >}}
```sh
# https://cdn.unrealengine.com/CrossToolchain_Linux/v25_clang-18.1.0-rockylinux8.exe
& "C:\Program Files\Epic Games\UE_5.7\Engine\Build\BatchFiles\Build.bat" Airse Linux Development -Project="C:\Users\syui\Documents\Unreal Projects\5.7\Airse\Airse.uproject" -WaitMutex -FromMsBuild
エラー: MSB3073
Airse: "C:\Program Files\Microsoft Visual Studio\2022\Community\MSBuild\Microsoft\VC\v170\Microsoft.MakeFile.Targets" 44
```
## ue5.7に移行する
最近はproject再構築と新しいbuild環境の構築をやっていたので、ついでに`5.7`に移行することにしました。まだ手を付けている部分が少ない時に移行したほうがよいと判断。

View File

@@ -1,24 +0,0 @@
---
title: "world origin systemができた"
slug: "dream-system"
date: "2025-10-08"
tags: ["ue"]
draft: false
---
## 現実と夢の世界
私のゲームでは、現実に合わせて世界を作るworld system(planet system)とゲームに合わせて作るorigin system(dream system)があります。
origin systemの由来は、地球平面説や天動説、つまり、古代の宇宙観です。惑星はお椀の形をしており、そこに地上が乗っているイメージ。
![](/img/ue_world_system_img_0001.png)
このorigin systemは、軽量性、独立性、統合性を考えられたデザインで、非常に扱いやすい形となっています。
![](/img/ue_world_system_img_0002.png)
今回は`ue5.6.1 -> ue5.7.0p`の移植作業が完了し、origin systemの仕様ができました。これで新しい惑星や地上の追加、統合が楽になります。
<iframe width="100%" height="415" src="https://www.youtube.com/embed/xQEGkTrJ45Y?rel=0&showinfo=0&controls=0" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>

View File

@@ -1,94 +0,0 @@
---
title: "comfyuiを自動化する"
slug: "comfyui-auto-controlnet"
date: "2025-10-19"
tags: ["comfyui"]
draft: false
---
今回は、`comfyui`の自動化を紹介します。
## comfyuiの自動化手順
以下の機能を使用します。
1. `Apply InstantID`: 顔を指定します。
2. `Apply ControlNet`: ポーズを指定します。
まずこちらのworkflowを読み込むと早く書けます。workflowは通常、comfyuiで作られた画像に記録されています。
[https://docs.comfy.org/tutorials/controlnet/pose-controlnet-2-pass](https://docs.comfy.org/tutorials/controlnet/pose-controlnet-2-pass)
ここから`Apply InstantID`を追加します。`Apply ControlNet`から`positive`, `negative``InstantID``KSampler`につなぎます。
```md
[ControlNet] -> [InstantID] -> [KSampler]
```
自動化には以下のノードを使います。
1. `Batch Image Loop Open`: loop処理を作れます。
2. `Load Image Batch From Dir`: 画像をディレクトリから読み込みます。
3. `LogicUtil_Uniform Random Choice`: ランダムで区切り文字を選択します。loop中にpromptの中身を変えます。
なお、`comfyui`の外部ノードは以下を使用しています。
- comfyui_instantid
- loop-image
- comfyui-inspire-pack
自動化の手順としては、まず、ポーズをディレクトリに保存しておき、`Load Image Batch From Dir`で読み込みます。`Batch Image Loop Open`につなぎます。それを`Apply ControlNet`につなぎます。
最終的に`KSampler`から`VAE Decode`をつなぎ、そこから`Batch Image Loop Close`でループを閉じます。
もしここで保存したければ、`VAE Decode``Save Image`にも繋いでおきます。
```md
[Load Image Batch From Dir] -> [Batch Image Loop Open] ->
[ControlNet] -> [InstantID] -> [KSampler] ->
[VAE Decode] -> [Batch Image Loop Close]
```
[![](/img/comfyui_instantid_controlnet_0001.png)](/img/comfyui_instantid_controlnet_0001.png)
[![](/img/comfyui_instantid_controlnet_0002.png)](/img/comfyui_instantid_controlnet_0002.png)
[![](/img/comfyui_instantid_controlnet_0003.png)](/img/comfyui_instantid_controlnet_0003.png)
[![](/img/comfyui_instantid_controlnet_0004.png)](/img/comfyui_instantid_controlnet_0004.png)
## comfyuiの便利なード
`filename_prefix``Get Date Time String(JPS)`を使用しています。これでファイル名が重複しづらくなります。
役立つ外部ノードです。
- comfy-image-saver
- JPS-Nodes
- comfyui-custom-scripts
例えば、loop中にpromptをランダムで変える処理を追加しています。これは`LogicUtil_Uniform Random Choice`で実現しており、区切り文字は`,`です。
```md
background: city street,
background: cloud sky,
background: galaxy planet,
```
## ポーズの作成手順
[https://openposes.com/](https://openposes.com/)
例えば、自作ゲーム動画を保存し、`ffmepg`で画像化します。
```sh
$ ffmpeg -i input.mp4 output%04d.png
```
その画像を使って、ポーズを作成することができます。
- `OpenPose Pose`: `comfyui_controlnet_aux`
[![](/img/comfyui_instantid_controlnet_0005.png)](/img/comfyui_instantid_controlnet_0005.png)

View File

@@ -1,115 +0,0 @@
---
title: "gpt-ossを使用する"
slug: "lms-gpt-oss"
date: "2025-10-19"
tags: ["openai", "AI", "windows"]
draft: false
---
今回は、openaiの[gpt-oss](https://huggingface.co/openai/gpt-oss-120b)を使用する方法です。
[https://openai.com/ja-JP/index/introducing-gpt-oss/](https://openai.com/ja-JP/index/introducing-gpt-oss/)
`120b`, `20b`があります。好きな方を使いましょう。ここでは`20b`を使用します。
```sh
$ ollama run gpt-oss:20b
or
$ lms get openai/gpt-oss-20b
```
今回は、lms(LM Studio)で使用します。
```sh
# https://lmstudio.ai/
$ pip install lmstudio
# https://huggingface.co/openai/gpt-oss-20b
$ lms get openai/gpt-oss-20b
```
今後、家庭のpcは、gpu(nvidia, amd)を積んで`lms``gpt-oss`を動かすのが一般的になりそう。
## サービスとして公開する
例えば、apiとして公開することもでき、それを自身のサービス上から利用するなどの使い方があります。なお、`lms`にもこのような機能はあります。
```sh
# https://cookbook.openai.com/articles/gpt-oss/run-transformers
$ transformers serve
$ transformers chat localhost:8000 --model-name-or-path openai/gpt-oss-20b
---
$ curl -X POST http://localhost:8000/v1/responses -H "Content-Type: application/json" -d '{"messages": [{"role": "system", "content": "hello"}], "temperature": 0.9, "max_tokens": 1000, "stream": true, "model": "openai/gpt-oss-20b"}'
```
```sh
$ cloudflared tunnel login
$ cloudflared tunnel create gpt-oss-tunnel
```
```yml:~/.cloudflared/config.yml
tunnel: 1234
credentials-file: ~/.cloudflared/1234.json
ingress:
- hostname: example.com
service: http://localhost:8000
- service: http_status:404
```
```sh
$ cloudflared tunnel run gpt-oss-tunnel
```
ただ、apiのreqにはキーとか設定しておいたほうがいいかも。
## 高速、大規模に使うには
`vllm`を使います。linuxが最適です。gpu(nvidia-cuda)がないときついので、win + wslで動かします。nvidiaの`H100`や`DGX Spark`が必要になると思います。
cudaはcomfyuiで使っている`cu129`に合わせました。
```sh
$ wsl --install archlinux
$ wsl -d archlinux
$ nvidia-smi
```
```sh
$ mkdir ~/.config/openai/gpt-oss
$ cd ~/.config/openai/gpt-oss
$ python -m venv venv
$ source venv/bin/activate
$ pip install --upgrade pip
$ pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu129
$ pip install vllm transformers
$ python -m vllm.entrypoints.openai.api_server \
--model openai/gpt-oss-20b \
--port 8000 \
```
```sh
$ curl http://localhost:8000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "openai/gpt-oss-20b",
"messages": [{"role": "user", "content": "こんにちは!"}]
}'
```
## お金の使い道
最近、iphoneやmacを買うより、`DGX Spark`を買ったほうが良いのではないかと考えることがあります。
[https://www.nvidia.com/ja-jp/products/workstations/dgx-spark/](https://www.nvidia.com/ja-jp/products/workstations/dgx-spark/)
pc(RTX)やmacは、60万円ほどかかりますし、それは`DGX Spark`の値段と同じです。どうせ同じ値段を使うなら、何を買うのが良いのでしょう。
パソコンのスペックというのは、毎年それほど変わりません。RTXにしても同じです。
とするなら、既に持っているものではなく、持っていないスパコンを購入し、そこにAIをホストしたり、あるいはその性能をpcから利用する事を考えたほうが良いのではないか。最近はそんなことをよく考えます。
今後はpcを買う時代ではなく、スパコンを買う時代に突入するかもしれません。

View File

@@ -1,24 +0,0 @@
---
title: "plamo-2で翻訳する"
slug: "lms-plamo-2"
date: "2025-10-19"
tags: ["lms", "AI", "windows"]
draft: false
---
今回は、`lms`で[pfnet/plamo-2-translate](https://huggingface.co/pfnet/plamo-2-translate)を使用する方法です。
![](/img/lms_plamo2_0001.png)
- [https://huggingface.co/mmnga/plamo-2-translate-gguf](https://huggingface.co/mmnga/plamo-2-translate-gguf)
- [https://huggingface.co/mmnga/plamo-2-translate-gguf/discussions/1/files](https://huggingface.co/mmnga/plamo-2-translate-gguf/discussions/1/files)
`lms``mmnga/plamo-2-translate-gguf`をdownloadして読み込みます。
次に、`discussions/1`にある`en2ja.preset.json`, `ja2en.preset.json`のファイルを保存するなり、作成して、それをプリセットからインポートします。
```sh
$ curl -sL "https://huggingface.co/mmnga/plamo-2-translate-gguf/raw/refs%2Fpr%2F1/plamo%202%20translate%20en2ja.preset.json" > en2ja.preset.json
$ curl -sL "https://huggingface.co/mmnga/plamo-2-translate-gguf/raw/refs%2Fpr%2F1/plamo%202%20translate%20ja2en.preset.json" > ja2en.preset.json
```

View File

@@ -1,36 +0,0 @@
---
title: "macbook air(mid 2011)のarchlinuxでフリーズ対応"
slug: "arch-macbook"
date: "2025-10-20"
tags: ["archlinux", "macbook"]
draft: false
---
今回はmacbook air(mid 2011)のarchlinux運用の話をします。
```sh
$ uname -r
6.12.53-1-lts
```
運用のコツとしては、`linux-lts`を使うこと。`linux-firmware`を入れないこと。`broadcom-wl`を入れること。
```sh
$ pacman -S linux-lts linux-lts-headers broadcom-wl
$ grub-mkconfig -o /boot/grub/grub.cfg
---
$ pacman -Qq | grep "^linux-firmware" | sudo pacman -R -
$ mkinitcpio -P
```
```sh:/etc/pacman.conf
IgnorePkg = linux linux-headers
```
## usbからの実行
```sh
$ mount /dev/sda2 /mnt
$ mount /dev/sda1 /mnt/boot
$ arch-chroot /mnt
```

View File

@@ -1,39 +0,0 @@
---
title: "archlinuxでvnc"
slug: "arch-vnc"
date: "2025-10-20"
tags: ["archlinux", "vnc", "mac"]
draft: false
---
自分のブログに書いてあると思ったんだけど、見当たらなかったので。
`xorg`, `i3`を使用しています。
```sh
$ sudo pacman -S tigervnc
```
```sh
$ vncpasswd
$ x0vncserver -rfbauth ~/.config/tigervnc/passwd
```
macから接続するには
```sh
$ open vnc://192.168.1.2:5900
```
## 操作感を同じにする
1. 自動起動
2. `Win`から`Alt`に変更。その上で`setxkbmap`でkey-layoutを変更。
```sh:~/.config/i3/config
exec --no-startup-id x0vncserver -rfbauth ~/.config/tigervnc/passwd
exec_always --no-startup-id setxkbmap -option altwin:swap_alt_win
#set $mod Mod4
set $mod Mod1
```

View File

@@ -1,75 +0,0 @@
---
title: "ゲームをiosに分離する"
slug: "aicard"
date: "2025-10-28"
tags: ["ue"]
draft: false
---
unreal engineで開発しているゲームの話です。ios buildでハマった部分がかなり多かったので紹介。
ゲーム自体は、カードとアクションを分けることにしました。
カードというのは道具ボックスのようなものです。これに関するゲームをiosでもできるようにする、という方向で作り直しています。
iosのゲーム機能はシンプルにカードを引く、集める、アカウント連携の3つです。
最初にカードをタップするとランダムでカードを取得します。1日1回を予定しています。課金要素では例えば、月額と単発があり、月額は3,000円、単発は150円を想定。
アカウント連携は、ローカルデータをatprotoのアカウントにセーブする機能のみ。
以上となります。後に機能を拡張していく予定です。
カード自体は本作のキャラクターを強化するものとしても使用することを想定しています。
<iframe width="100%" height="415" src="https://www.youtube.com/embed/SdAiRskyrew?rel=0&showinfo=0&controls=0" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
## buildが失敗する
これは無料アカウントでは難しいです。apple devに年会費を払いましょう。
```sh:./Config/DeafaultEngine.ini
[/Script/IOSRuntimeSettings.IOSRuntimeSettings]
BundleDisplayName=Aicard
BundleIdentifier=ai.syui.card
IOSTeamID=xxx
BundleName=ai.syui.card
MetalLanguageVersion=9
MinimumiOSVersion=IOS_26
bAutomaticSigning=True
RemoteNotificationsSupported=False
bSupportsPortraitOrientation=True
bSupportsLandscapeLeftOrientation=False
bSupportsLandscapeRightOrientation=False
[/Script/MacTargetPlatform.XcodeProjectSettings]
CodeSigningTeam=xxx
bUseModernXcode=true
bUseAutomaticCodeSigning=true
BundleIdentifier=ai.syui.card
```
## iconが設定できない
project-rootに`./Build/IOS/Resources/Assets.xcassets`を用意します。これはxcodeから持ってきます。build実行時に作られます。
![](/img/ue_ios_aicard_0001.png)
## 画面をモバイルにあわせる
これが一番時間がかかりました。buildして実機で確認する必要があるからです。
真ん中だけ全体化したうえで、それを上下メニューバーに合わせます。
![](/img/ue_ios_aicard_0004.png)
![](/img/ue_ios_aicard_0005.png)
![](/img/ue_ios_aicard_0003.png)
## widgetのリスト化
これも相当面倒でした。`json`からリストを取得して、それを表示します。
クラス設定で`UserObjectListEntry`を追加し、それを使用します。
<iframe src="https://blueprintue.com/render/wz8aaem4" scrolling="no" allowfullscreen style="width:100%;height:400px"></iframe>

View File

@@ -1,61 +0,0 @@
<!-- 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

@@ -1,152 +0,0 @@
<!DOCTYPE html>
<html lang="{{ config.language }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{{ config.title }}{% endblock %}</title>
<!-- Favicon -->
<link rel="icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<!-- 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">
{% block head %}{% endblock %}
</head>
<body>
<div class="container">
<header class="main-header">
<div class="header-content">
<h1><a href="/" class="site-title">{{ config.title }}</a></h1>
<div class="logo">
<a href="/">
<svg width="77pt" height="77pt" viewBox="0 0 512 512" class="likeButton">
<circle class="explosion" r="150" cx="250" cy="250"></circle>
<g class="particleLayer">
<circle fill="#8CE8C3" cx="130" cy="126.5" r="12.5"></circle>
<circle fill="#8CE8C3" cx="411" cy="313.5" r="12.5"></circle>
<circle fill="#91D2FA" cx="279" cy="86.5" r="12.5"></circle>
<circle fill="#91D2FA" cx="155" cy="390.5" r="12.5"></circle>
<circle fill="#CC8EF5" cx="89" cy="292.5" r="10.5"></circle>
<circle fill="#9BDFBA" cx="414" cy="282.5" r="10.5"></circle>
<circle fill="#9BDFBA" cx="115" cy="149.5" r="10.5"></circle>
<circle fill="#9FC7FA" cx="250" cy="80.5" r="10.5"></circle>
<circle fill="#9FC7FA" cx="78" cy="261.5" r="10.5"></circle>
<circle fill="#96D8E9" cx="182" cy="402.5" r="10.5"></circle>
<circle fill="#CC8EF5" cx="401.5" cy="166" r="13"></circle>
<circle fill="#DB92D0" cx="379" cy="141.5" r="10.5"></circle>
<circle fill="#DB92D0" cx="327" cy="397.5" r="10.5"></circle>
<circle fill="#DD99B8" cx="296" cy="392.5" r="10.5"></circle>
</g>
<g transform="translate(0,512) scale(0.1,-0.1)" fill="#000000" class="icon_syui">
<path class="syui" d="M3660 4460 c-11 -11 -33 -47 -48 -80 l-29 -60 -12 38 c-27 88 -58 92 -98 11 -35 -70 -73 -159 -73 -169 0 -6 -5 -10 -10 -10 -6 0 -15 -10 -21 -22 -33 -73 -52 -92 -47 -48 2 26 -1 35 -14 38 -16 3 -168 -121 -168 -138 0 -5 -13 -16 -28 -24 -24 -13 -35 -12 -87 0 -221 55 -231 56 -480 56 -219 1 -247 -1 -320 -22 -44 -12 -96 -26 -115 -30 -57 -13 -122 -39 -200 -82 -8 -4 -31 -14 -50 -23 -41 -17 -34 -13 -146 -90 -87 -59 -292 -252 -351 -330 -63 -83 -143 -209 -143 -225 0 -10 -7 -23 -15 -30 -8 -7 -15 -17 -15 -22 0 -5 -13 -37 -28 -71 -16 -34 -36 -93 -45 -132 -9 -38 -24 -104 -34 -145 -13 -60 -17 -121 -17 -300 1 -224 1 -225 36 -365 24 -94 53 -175 87 -247 28 -58 51 -108 51 -112 0 -3 13 -24 28 -48 42 -63 46 -79 22 -85 -11 -3 -20 -9 -20 -14 0 -5 -4 -9 -10 -9 -5 0 -22 -11 -37 -25 -16 -13 -75 -59 -133 -100 -58 -42 -113 -82 -123 -90 -9 -8 -22 -15 -27 -15 -6 0 -10 -6 -10 -13 0 -8 -11 -20 -25 -27 -34 -18 -34 -54 0 -48 14 3 25 2 25 -1 0 -3 -43 -31 -95 -61 -52 -30 -95 -58 -95 -62 0 -5 -5 -8 -11 -8 -19 0 -84 -33 -92 -47 -4 -7 -15 -13 -22 -13 -14 0 -17 -4 -19 -32 -1 -8 15 -15 37 -18 l38 -5 -47 -48 c-56 -59 -54 -81 9 -75 30 3 45 0 54 -11 9 -13 16 -14 43 -4 29 11 30 10 18 -5 -7 -9 -19 -23 -25 -30 -7 -7 -13 -20 -13 -29 0 -12 8 -14 38 -9 20 4 57 8 82 9 25 2 54 8 66 15 18 10 23 8 32 -13 17 -38 86 -35 152 6 27 17 50 34 50 38 0 16 62 30 85 19 33 -15 72 -2 89 30 8 15 31 43 51 62 35 34 38 35 118 35 77 0 85 2 126 33 24 17 52 32 61 32 9 0 42 18 73 40 30 22 61 40 69 40 21 0 88 -26 100 -38 7 -7 17 -12 24 -12 7 0 35 -11 62 -25 66 -33 263 -84 387 -101 189 -25 372 -12 574 41 106 27 130 37 261 97 41 20 80 37 85 39 6 2 51 31 100 64 166 111 405 372 489 534 10 20 22 43 27 51 5 8 12 22 15 30 3 8 17 40 31 70 54 115 95 313 108 520 13 200 -43 480 -134 672 -28 58 -51 108 -51 112 0 3 -13 24 -29 48 -15 24 -34 60 -40 80 -19 57 3 142 50 193 10 11 22 49 28 85 6 36 16 67 21 68 18 6 31 53 25 83 -4 18 -17 33 -36 41 -16 7 -29 15 -29 18 1 10 38 50 47 50 5 0 20 11 33 25 18 19 22 31 17 61 -3 20 -14 45 -23 55 -16 18 -16 20 6 44 15 16 21 32 18 49 -3 15 1 34 8 43 32 43 7 73 -46 55 l-30 -11 0 85 c0 74 -2 84 -18 84 -21 0 -53 -33 -103 -104 l-34 -48 -5 74 c-7 102 -35 133 -80 88z m-870 -740 c36 -7 75 -14 88 -16 21 -4 23 -9 16 -37 -3 -18 -14 -43 -24 -57 -10 -14 -20 -35 -24 -46 -4 -12 -16 -32 -27 -45 -12 -13 -37 -49 -56 -79 -20 -30 -52 -73 -72 -96 -53 -60 -114 -133 -156 -189 -21 -27 -44 -54 -52 -58 -7 -4 -13 -14 -13 -22 0 -7 -18 -33 -40 -57 -22 -23 -40 -46 -40 -50 0 -5 -19 -21 -42 -38 -47 -35 -85 -38 -188 -15 -115 25 -173 20 -264 -23 -45 -22 -106 -46 -136 -56 -48 -15 -77 -25 -140 -50 -70 -28 -100 -77 -51 -84 14 -2 34 -10 45 -17 12 -7 53 -16 91 -20 90 -9 131 -22 178 -57 20 -16 52 -35 70 -43 18 -7 40 -22 49 -32 16 -18 15 -22 -24 -88 -23 -39 -47 -74 -53 -80 -7 -5 -23 -26 -36 -45 -26 -39 -92 -113 -207 -232 -4 -4 -37 -36 -73 -71 l-66 -64 -20 41 c-58 119 -105 240 -115 301 -40 244 -35 409 20 595 8 30 21 66 28 80 7 14 24 54 38 89 15 35 35 75 46 89 11 13 20 31 20 38 0 8 3 14 8 14 4 0 16 16 27 36 24 45 221 245 278 281 23 15 44 30 47 33 20 20 138 78 250 123 61 24 167 50 250 61 60 7 302 -1 370 -14z m837 -661 c52 -101 102 -279 106 -379 2 -42 0 -45 -28 -51 -16 -4 -101 -7 -187 -8 -166 -1 -229 10 -271 49 -19 19 -19 19 14 49 22 21 44 31 65 31 41 0 84 34 84 66 0 30 12 55 56 112 19 25 37 65 44 95 11 51 53 111 74 104 6 -2 25 -32 43 -68z m-662 -810 c17 -10 40 -24 53 -30 12 -7 22 -16 22 -20 0 -4 17 -13 38 -19 20 -7 44 -18 52 -24 8 -7 33 -21 55 -31 22 -11 42 -23 45 -26 11 -14 109 -49 164 -58 62 -11 101 -7 126 14 15 14 38 18 78 16 39 -2 26 -41 -49 -146 -78 -109 -85 -118 -186 -219 -61 -61 -239 -189 -281 -203 -17 -5 -73 -29 -104 -44 -187 -92 -605 -103 -791 -21 -42 19 -47 24 -37 41 5 11 28 32 51 48 22 15 51 38 64 51 13 12 28 22 33 22 17 0 242 233 242 250 0 6 5 10 10 10 6 0 10 6 10 14 0 25 50 55 100 62 59 8 56 6 115 83 50 66 74 117 75 162 0 14 7 40 16 57 18 38 52 41 99 11z"></path>
</g>
</svg>
</a>
</div>
<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
</button>
</div>
</div>
</header>
<!-- Ask AI Panel -->
<div class="ask-ai-panel" id="askAiPanel" style="display: none;">
<div class="ask-ai-content">
<div id="authCheck" class="auth-check">
<div class="loading-content">
<div class="loading-spinner"></div>
<p>Loading...</p>
</div>
</div>
<div id="chatForm" class="ask-ai-form" style="display: none;">
<input type="text" id="aiQuestion" placeholder="What would you like to know?" />
<button onclick="askQuestion()" id="askButton">Ask</button>
</div>
<div id="chatHistory" class="chat-history" style="display: none;"></div>
</div>
</div>
<main class="main-content">
<!-- Pds Panel -->
{% include "pds-header.html" %}
{% block content %}{% endblock %}
</main>
{% block sidebar %}{% endblock %}
</div>
<footer class="main-footer">
<div class="footer-social">
<a href="https://syu.is/syui" target="_blank"><i class="fab fa-bluesky"></i></a>
<a href="https://git.syui.ai/ai" target="_blank"><span class="icon-ai"></span></a>
<a href="https://github.com/syui" target="_blank"><i class="fab fa-github"></i></a>
</div>
<p>© {{ config.author }}</p>
</footer>
<script>
// Config variables from Hugo
window.OAUTH_CONFIG = {
{% if config.oauth.pds %}
pds: "{{ config.oauth.pds }}",
{% else %}
pds: "syu.is",
{% endif %}
{% if config.oauth.admin %}
admin: "{{ config.oauth.admin }}",
{% else %}
admin: "ai.syui.ai",
{% endif %}
{% if config.oauth.collection %}
collection: "{{ config.oauth.collection }}"
{% else %}
collection: "ai.syui.log"
{% endif %}
};
</script>
<script src="/js/ask-ai.js"></script>
<script src="/js/pds.js"></script>
<script src="/js/theme.js"></script>
<script src="/js/image-comparison.js"></script>
<!-- Mermaid support -->
<script src="https://cdn.jsdelivr.net/npm/mermaid@10.6.1/dist/mermaid.min.js"></script>
<script>
mermaid.initialize({
startOnLoad: true,
theme: 'neutral',
securityLevel: 'loose',
themeVariables: {
fontFamily: 'system-ui, -apple-system, sans-serif',
fontSize: '14px'
}
});
</script>
{% include "oauth-assets.html" %}
{% include "at-browser-assets.html" %}
</body>
</html>

View File

@@ -1,135 +0,0 @@
{% 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

@@ -1,45 +0,0 @@
{% extends "base.html" %}
{% block content %}
<div class="timeline-container">
<div class="timeline-feed">
{% for post in posts %}
<article class="timeline-post">
<div class="post-header">
<div class="post-meta">
<time class="post-date">{{ post.date }}</time>
{% if post.language %}
<span class="post-lang">{{ post.language }}</span>
{% endif %}
{% if post.type == "ai" %}
<span class="post-ai">
<span class="ai-icon icon-ai"></span>
ai
</span>
{% endif %}
</div>
</div>
<div class="post-content">
<h3 class="post-title">
<a href="{{ post.url }}">{{ post.title }}</a>
</h3>
</div>
</article>
{% endfor %}
</div>
<!-- OAuth Comment System -->
<section class="comment-section">
<div id="comment-atproto"></div>
</section>
{% if posts|length == 0 %}
<div class="empty-state">
<p>No posts yet. Start writing!</p>
</div>
{% endif %}
</div>
{% endblock %}

View File

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

View File

@@ -1,71 +0,0 @@
<!-- OAuth authentication widget for ailog -->
<div id="oauth-widget">
<div id="status" class="status">
Login with your Bluesky account
</div>
<!-- Login form -->
<div id="login-form">
<input type="text" id="handle-input" placeholder="Enter your handle (e.g., user.bsky.social)" style="width: 300px; padding: 10px; margin: 10px 0; border: 1px solid #ddd; border-radius: 4px;">
<br>
<button id="login-btn">🦋 Login with Bluesky</button>
</div>
<!-- Authenticated state -->
<div id="authenticated-state" style="display: none;">
<div id="user-info"></div>
<button id="logout-btn">Logout</button>
<button id="test-profile-btn">Get Profile</button>
</div>
<div id="console-log" class="log"></div>
</div>
<script src="/oauth-widget-simple.js"></script>
<style>
.status {
margin: 20px 0;
padding: 15px;
border-radius: 8px;
background: #f5f5f5;
}
.user-info {
background: #e8f5e8;
border: 1px solid #4caf50;
}
.error {
background: #ffeaea;
border: 1px solid #f44336;
color: #d32f2f;
}
#oauth-widget button {
background: #1185fe;
color: white;
border: none;
padding: 12px 24px;
border-radius: 6px;
font-size: 16px;
cursor: pointer;
margin: 10px;
}
#oauth-widget button:hover {
background: #0d6efd;
}
#oauth-widget button:disabled {
background: #6c757d;
cursor: not-allowed;
}
.log {
text-align: left;
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 6px;
padding: 15px;
margin: 20px 0;
max-height: 300px;
overflow-y: auto;
font-family: monospace;
font-size: 14px;
}
</style>

View File

@@ -1,48 +0,0 @@
<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

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

View File

@@ -1,373 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ post.title }} - {{ config.title }}{% endblock %}
{% block content %}
<div class="article-container">
<article class="article-content">
<header class="article-header">
<h1 class="article-title">{{ post.title }}</h1>
<div class="article-meta">
<time class="article-date">{{ post.date }}</time>
{% if post.language %}
<span class="article-lang">{{ post.language }}</span>
{% endif %}
</div>
<div class="article-actions">
{% if post.markdown_url %}
<a href="{{ post.markdown_url }}" class="action-btn markdown-btn" title="View Markdown">
.md
</a>
{% endif %}
{% if post.translation_url %}
<a href="{{ post.translation_url }}" class="action-btn translation-btn" title="View Translation">
🌐 {% if post.language == 'ja' %}English{% else %}日本語{% endif %}
</a>
{% endif %}
</div>
</header>
<div class="article-body">
{{ post.content | safe }}
</div>
<!-- Comment Section -->
<section class="comment-section">
<div class="comment-container">
<h3>Comments</h3>
<!-- ATProto Auth Widget Container -->
<div id="atproto-auth-widget" class="comment-auth"></div>
<div id="commentForm" class="comment-form" style="display: none;">
<textarea id="commentText" placeholder="Share your thoughts..." rows="4"></textarea>
<button onclick="submitComment()" class="submit-btn">Post Comment</button>
</div>
<div id="commentsList" class="comments-list">
<!-- Comments will be loaded here -->
</div>
</div>
</section>
</article>
<aside class="article-sidebar">
<nav class="toc">
<h3>Contents</h3>
<div id="toc-content">
<!-- TOC will be generated by JavaScript -->
</div>
</nav>
</aside>
</div>
{% endblock %}
{% block sidebar %}
<!-- Include ATProto Libraries via script tags (more reliable than dynamic imports) -->
<script src="https://cdn.jsdelivr.net/npm/@atproto/oauth-client-browser@latest/dist/index.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@atproto/api@latest/dist/index.js"></script>
<!-- Fallback: Try multiple CDNs -->
<script>
console.log('Checking ATProto library availability...');
// Check if libraries loaded successfully
if (typeof ATProto === 'undefined' && typeof window.ATProto === 'undefined') {
console.log('Primary CDN failed, trying fallback...');
// Create fallback script elements
const fallbackScripts = [
'https://unpkg.com/@atproto/oauth-client-browser@latest/dist/index.js',
'https://esm.sh/@atproto/oauth-client-browser',
'https://cdn.skypack.dev/@atproto/oauth-client-browser'
];
// Load fallback scripts sequentially
let scriptIndex = 0;
function loadNextScript() {
if (scriptIndex < fallbackScripts.length) {
const script = document.createElement('script');
script.src = fallbackScripts[scriptIndex];
script.onload = () => {
console.log(`Loaded from fallback CDN: ${fallbackScripts[scriptIndex]}`);
window.atprotoLibrariesReady = true;
};
script.onerror = () => {
console.log(`Failed to load from: ${fallbackScripts[scriptIndex]}`);
scriptIndex++;
loadNextScript();
};
document.head.appendChild(script);
} else {
console.error('All CDN fallbacks failed');
window.atprotoLibrariesReady = false;
}
}
loadNextScript();
} else {
console.log('✅ ATProto libraries loaded from primary CDN');
window.atprotoLibrariesReady = true;
}
</script>
<!-- Simple ATProto Widget (no external dependency) -->
<link rel="stylesheet" href="/atproto-auth-widget/dist/atproto-auth.min.css">
<script>
// Initialize auth widget
let authWidget = null;
document.addEventListener('DOMContentLoaded', function() {
generateTableOfContents();
initializeAuthWidget();
loadComments();
});
function generateTableOfContents() {
const tocContainer = document.getElementById('toc-content');
const headings = document.querySelectorAll('.article-body h1, .article-body h2, .article-body h3, .article-body h4, .article-body h5, .article-body h6');
if (headings.length === 0) {
tocContainer.innerHTML = '<p class="no-toc">No headings found</p>';
return;
}
const tocList = document.createElement('ul');
tocList.className = 'toc-list';
headings.forEach((heading, index) => {
const id = `heading-${index}`;
heading.id = id;
const listItem = document.createElement('li');
listItem.className = `toc-item toc-${heading.tagName.toLowerCase()}`;
const link = document.createElement('a');
link.href = `#${id}`;
link.textContent = heading.textContent;
link.className = 'toc-link';
// Smooth scroll behavior
link.addEventListener('click', function(e) {
e.preventDefault();
heading.scrollIntoView({ behavior: 'smooth' });
});
listItem.appendChild(link);
tocList.appendChild(listItem);
});
tocContainer.appendChild(tocList);
}
// Initialize ATProto Auth Widget
async function initializeAuthWidget() {
try {
// Check WebCrypto API availability
console.log('WebCrypto check:', {
available: !!window.crypto && !!window.crypto.subtle,
secureContext: window.isSecureContext,
protocol: window.location.protocol,
hostname: window.location.hostname
});
if (!window.crypto || !window.crypto.subtle) {
throw new Error('WebCrypto API is not available. This requires HTTPS or localhost.');
}
if (!window.isSecureContext) {
console.warn('Not in secure context - WebCrypto may not work properly');
}
// Simplified approach: Show manual OAuth form
console.log('Using simplified OAuth approach...');
showSimpleOAuthForm();
// Fallback to widget initialization
authWidget = await window.initATProtoWidget('#atproto-auth-widget', {
clientId: clientId,
onLogin: (session) => {
console.log('User logged in:', session.handle);
document.getElementById('commentForm').style.display = 'block';
},
onLogout: () => {
console.log('User logged out');
document.getElementById('commentForm').style.display = 'none';
},
onError: (error) => {
console.error('ATProto Auth Error:', error);
// Show user-friendly error message
const authContainer = document.getElementById('atproto-auth-widget');
if (authContainer) {
let errorMessage = 'Authentication service is temporarily unavailable.';
let suggestion = 'Please try refreshing the page.';
if (error.message && error.message.includes('WebCrypto')) {
errorMessage = 'This feature requires a secure HTTPS connection.';
suggestion = 'Please ensure you are accessing via https://log.syui.ai';
}
authContainer.innerHTML = `
<div class="atproto-auth__fallback">
<p>${errorMessage}</p>
<p>${suggestion}</p>
<details style="margin-top: 10px; font-size: 0.8em; color: #666;">
<summary>Technical details</summary>
<pre>${error.message || 'Unknown error'}</pre>
</details>
</div>
`;
}
},
theme: 'default'
});
} else if (typeof window.ATProtoAuthWidget === 'function') {
// Fallback to direct widget initialization
authWidget = new window.ATProtoAuthWidget({
containerSelector: '#atproto-auth-widget',
clientId: clientId,
onLogin: (session) => {
console.log('User logged in:', session.handle);
document.getElementById('commentForm').style.display = 'block';
},
onLogout: () => {
console.log('User logged out');
document.getElementById('commentForm').style.display = 'none';
},
onError: (error) => {
console.error('ATProto Auth Error:', error);
const authContainer = document.getElementById('atproto-auth-widget');
if (authContainer) {
authContainer.innerHTML = `
<div class="atproto-auth__fallback">
<p>Authentication service is temporarily unavailable.</p>
<p>Please try refreshing the page.</p>
</div>
`;
}
},
theme: 'default'
});
await authWidget.init();
} else {
throw new Error('ATProto widget not available');
}
} catch (error) {
console.error('Failed to initialize auth widget:', error);
// Show fallback UI
const authContainer = document.getElementById('atproto-auth-widget');
if (authContainer) {
authContainer.innerHTML = `
<div class="atproto-auth__fallback">
<p>Authentication widget failed to load.</p>
<p>Please check your internet connection and refresh the page.</p>
</div>
`;
}
}
}
async function submitComment() {
const commentText = document.getElementById('commentText').value.trim();
if (!commentText || !authWidget.isLoggedIn()) {
alert('Please login and enter a comment');
return;
}
try {
const postSlug = '{{ post.slug }}';
const postUrl = window.location.href;
const createdAt = new Date().toISOString();
// Create comment record using the auth widget
const response = await authWidget.createRecord('ai.log.comment', {
$type: 'ai.log.comment',
text: commentText,
post_slug: postSlug,
post_url: postUrl,
createdAt: createdAt
});
console.log('Comment posted:', response);
document.getElementById('commentText').value = '';
loadComments();
} catch (error) {
console.error('Comment submission failed:', error);
alert('Failed to post comment: ' + error.message);
}
}
function showAuthenticatedState(session) {
const authContainer = document.getElementById('atproto-auth-widget');
const agent = new window.ATProtoAgent(session);
authContainer.innerHTML = `
<div class="atproto-auth__authenticated">
<p>✅ Authenticated as: <strong>${session.did}</strong></p>
<button id="logout-btn" class="atproto-auth__button">Logout</button>
</div>
`;
document.getElementById('logout-btn').onclick = async () => {
await session.signOut();
window.location.reload();
};
// Show comment form
document.getElementById('commentForm').style.display = 'block';
window.currentSession = session;
window.currentAgent = agent;
}
function showLoginForm(oauthClient) {
const authContainer = document.getElementById('atproto-auth-widget');
authContainer.innerHTML = `
<div class="atproto-auth__login">
<h4>Login with ATProto</h4>
<input type="text" id="handle-input" placeholder="user.bsky.social" />
<button id="login-btn" class="atproto-auth__button">Connect</button>
</div>
`;
document.getElementById('login-btn').onclick = async () => {
const handle = document.getElementById('handle-input').value.trim();
if (!handle) {
alert('Please enter your handle');
return;
}
try {
const url = await oauthClient.authorize(handle);
window.open(url, '_self', 'noopener');
} catch (error) {
console.error('OAuth authorization failed:', error);
alert('Authentication failed: ' + error.message);
}
};
// Enter key support
document.getElementById('handle-input').onkeypress = (e) => {
if (e.key === 'Enter') {
document.getElementById('login-btn').click();
}
};
}
async function loadComments() {
try {
const commentsList = document.getElementById('commentsList');
commentsList.innerHTML = '<p class="loading">Loading comments from ATProto network...</p>';
// In a real implementation, you would query an aggregation service
// For demo, show empty state
setTimeout(() => {
commentsList.innerHTML = '<p class="no-comments">Comments will appear here when posted via ATProto.</p>';
}, 1000);
} catch (error) {
console.error('Failed to load comments:', error);
document.getElementById('commentsList').innerHTML = '<p class="error">Failed to load comments</p>';
}
}
</script>
{% endblock %}

View File

@@ -1,196 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ post.title }} - {{ config.title }}{% endblock %}
{% block content %}
<div class="article-container">
<article class="article-content">
<header class="article-header">
<h1 class="article-title">{{ post.title }}</h1>
<div class="article-meta">
<time class="article-date">{{ post.date }}</time>
{% if post.language %}
<span class="article-lang">{{ post.language }}</span>
{% endif %}
</div>
<div class="article-actions">
{% if post.markdown_url %}
<a href="{{ post.markdown_url }}" class="action-btn markdown-btn" title="View Markdown">
.md
</a>
{% endif %}
{% if post.translation_url %}
<a href="{{ post.translation_url }}" class="action-btn translation-btn" title="View Translation">
🌐 {% if post.language == 'ja' %}English{% else %}日本語{% endif %}
</a>
{% endif %}
</div>
</header>
<div class="article-body">
{{ post.content | safe }}
</div>
<!-- Simple Comment Section -->
<section class="comment-section">
<div class="comment-container">
<h3>Comments</h3>
<!-- Simple OAuth Button -->
<div class="simple-oauth">
<p>📝 To comment, authenticate with Bluesky:</p>
<button id="bluesky-auth" class="oauth-button">
🦋 Login with Bluesky
</button>
<p class="oauth-note">
<small>After authentication, you can post comments that will be stored in your ATProto PDS.</small>
</p>
</div>
<div id="comments-list" class="comments-list">
<p class="no-comments">Comments will appear here when posted via ATProto.</p>
</div>
</div>
</section>
</article>
<aside class="article-sidebar">
<nav class="toc">
<h3>Contents</h3>
<div id="toc-content">
<!-- TOC will be generated by JavaScript -->
</div>
</nav>
</aside>
</div>
{% endblock %}
{% block sidebar %}
<script>
document.addEventListener('DOMContentLoaded', function() {
generateTableOfContents();
initializeSimpleAuth();
});
function generateTableOfContents() {
const tocContainer = document.getElementById('toc-content');
const headings = document.querySelectorAll('.article-body h1, .article-body h2, .article-body h3, .article-body h4, .article-body h5, .article-body h6');
if (headings.length === 0) {
tocContainer.innerHTML = '<p class="no-toc">No headings found</p>';
return;
}
const tocList = document.createElement('ul');
tocList.className = 'toc-list';
headings.forEach((heading, index) => {
const id = `heading-${index}`;
heading.id = id;
const listItem = document.createElement('li');
listItem.className = `toc-item toc-${heading.tagName.toLowerCase()}`;
const link = document.createElement('a');
link.href = `#${id}`;
link.textContent = heading.textContent;
link.className = 'toc-link';
link.addEventListener('click', function(e) {
e.preventDefault();
heading.scrollIntoView({ behavior: 'smooth' });
});
listItem.appendChild(link);
tocList.appendChild(listItem);
});
tocContainer.appendChild(tocList);
}
function initializeSimpleAuth() {
const authButton = document.getElementById('bluesky-auth');
authButton.addEventListener('click', function() {
// Simple approach: Direct redirect to Bluesky OAuth
const isProduction = window.location.hostname === 'log.syui.ai';
const clientId = isProduction
? 'https://log.syui.ai/client-metadata.json'
: window.location.origin + '/client-metadata.json';
const authUrl = `https://bsky.social/oauth/authorize?` +
`client_id=${encodeURIComponent(clientId)}&` +
`redirect_uri=${encodeURIComponent(window.location.href)}&` +
`response_type=code&` +
`scope=atproto%20transition:generic&` +
`state=demo-state`;
console.log('Redirecting to:', authUrl);
// Open in new tab for now (safer for testing)
window.open(authUrl, '_blank');
// Show status message
authButton.innerHTML = '✅ Check the new tab for authentication';
authButton.disabled = true;
});
// Check if we're returning from OAuth
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('code')) {
console.log('OAuth callback detected:', urlParams.get('code'));
document.querySelector('.simple-oauth').innerHTML = `
<div class="oauth-success">
✅ OAuth callback received!<br>
<small>Code: ${urlParams.get('code')}</small><br>
<small>In a full implementation, this would exchange the code for tokens.</small>
</div>
`;
}
}
</script>
<style>
.simple-oauth {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 20px;
margin: 20px 0;
text-align: center;
}
.oauth-button {
background: #1185fe;
color: white;
border: none;
padding: 12px 24px;
border-radius: 6px;
font-size: 16px;
cursor: pointer;
margin: 10px 0;
}
.oauth-button:hover {
background: #0d6efd;
}
.oauth-button:disabled {
background: #6c757d;
cursor: not-allowed;
}
.oauth-note {
color: #6c757d;
font-style: italic;
}
.oauth-success {
background: #d1edff;
border: 1px solid #b6d7ff;
border-radius: 4px;
padding: 15px;
color: #0c5460;
}
</style>
{% endblock %}

View File

@@ -1,106 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ post.title }} - {{ config.title }}{% endblock %}
{% block content %}
<div class="article-container">
<article class="article-content">
<header class="article-header">
<h1 class="article-title">{{ post.title }}</h1>
<div class="article-meta">
<time class="article-date">{{ post.date }}</time>
{% if post.language %}
<span class="article-lang">{{ post.language }}</span>
{% endif %}
{% if post.extra.type == "ai" %}
<span class="article-ai">
<span class="ai-icon icon-ai"></span>
ai
</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">
.md
</a>
{% endif %}
{% if post.translation_url %}
<a href="{{ post.translation_url }}" class="action-btn translation-btn" title="View Translation">
🌐 {% if post.language == 'ja' %}English{% else %}日本語{% endif %}
</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">
<!-- TOC will be generated by JavaScript -->
</div>
</nav>
<div class="article-body">
{{ post.content | safe }}
</div>
{% endif %}
<div id="comment-atproto"></div>
</article>
</div>
<script>
// Generate table of contents
function generateTableOfContents() {
const tocContainer = document.getElementById('toc-content');
if (!tocContainer) {
return;
}
const headings = document.querySelectorAll('.article-body h1, .article-body h2, .article-body h3, .article-body h4, .article-body h5, .article-body h6');
if (headings.length === 0) {
tocContainer.innerHTML = '<p class="no-toc">No headings found</p>';
return;
}
const tocList = document.createElement('ul');
tocList.className = 'toc-list';
headings.forEach((heading, index) => {
const id = `heading-${index}`;
heading.id = id;
const listItem = document.createElement('li');
listItem.className = `toc-item toc-${heading.tagName.toLowerCase()}`;
const link = document.createElement('a');
link.href = `#${id}`;
link.textContent = heading.textContent;
link.className = 'toc-link';
link.addEventListener('click', function(e) {
e.preventDefault();
heading.scrollIntoView({ behavior: 'smooth' });
});
listItem.appendChild(link);
tocList.appendChild(listItem);
});
tocContainer.appendChild(tocList);
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', () => {
generateTableOfContents();
});
</script>
{% endblock %}
{% block sidebar %}
{% endblock %}

View File

@@ -1,345 +0,0 @@
@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: 17px 24px;
padding: 16px 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: 25px;
font-size: 20px;
transition: all 0.2s ease;
}
@@ -951,11 +951,9 @@ article.article-content {
max-width: 100% !important;
}
/*
.form-group {
margin-bottom: 15px !important;
}
*/
.form-input, .form-textarea {
width: 100% !important;
@@ -1840,17 +1838,3 @@ 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.

Before

Width:  |  Height:  |  Size: 260 KiB

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 520 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 766 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 888 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 723 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1008 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 347 KiB

View File

@@ -1,370 +0,0 @@
// 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

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

View File

@@ -1,135 +0,0 @@
{% 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

@@ -12,12 +12,6 @@
{% if post.language %}
<span class="post-lang">{{ post.language }}</span>
{% endif %}
{% if post.extra and post.extra.type == "ai" %}
<span class="post-ai">
<span class="ai-icon icon-ai"></span>
ai
</span>
{% endif %}
</div>
</div>

View File

@@ -1,48 +0,0 @@
<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

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

View File

@@ -12,14 +12,8 @@
{% if post.language %}
<span class="article-lang">{{ post.language }}</span>
{% endif %}
{% if post.extra and post.extra.type == "ai" %}
<span class="article-ai">
<span class="ai-icon icon-ai"></span>
ai
</span>
{% endif %}
</div>
{% if not post.extra or not post.extra.type or post.extra.type != "ai" %}
{% 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">
@@ -35,7 +29,7 @@
{% endif %}
</header>
{% if not post.extra or not post.extra.type or post.extra.type != "ai" %}
{% if not post.extra.type or post.extra.type != "ai" %}
<nav class="toc">
<h3>Contents</h3>
<div id="toc-content">

View File

@@ -1,6 +1,6 @@
{
"name": "ailog-oauth",
"version": "0.3.4",
"version": "0.2.9",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -139,7 +139,7 @@ body {
/* align-items: center; */
max-width: 800px;
margin: 0 auto;
padding: 30px 0;
padding: 25px 0;
width: 100%;
}
@@ -287,6 +287,7 @@ body {
.auth-section {
display: flex;
align-items: center;
gap: 8px;
}
.auth-section.search-bar-layout {
@@ -301,10 +302,10 @@ body {
.auth-section.search-bar-layout .handle-input {
flex: 1;
margin: 0;
padding: 9px 15px;
font-size: 13px;
padding: 10px 15px;
font-size: 16px;
border: 1px solid var(--border);
border-radius: 4px 0 0 4px;
border-radius: 8px 0 0 8px;
background: var(--background);
outline: none;
transition: border-color 0.2s;
@@ -318,13 +319,12 @@ body {
}
.auth-section.search-bar-layout .auth-button {
border-radius: 0 4px 4px 0;
border-radius: 0 6px 6px 0;
border: 1px solid var(--primary);
border-left: none;
margin: 0;
padding: 9px 15px;
min-width: 50px;
min-height: 30px;
padding: 10px 15px;
height: 40px;
}
/* Auth Button */
@@ -332,26 +332,15 @@ body {
background: var(--primary);
color: white;
border: none;
border-radius: 4px;
padding: 9px 15px;
border-radius: 6px;
padding: 8px 16px;
font-weight: 700;
cursor: pointer;
transition: background 0.2s;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
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 {
@@ -433,6 +422,10 @@ body {
margin-bottom: 16px;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
font-weight: 700;
@@ -926,6 +919,10 @@ body {
max-width: 100% !important;
}
.form-group {
margin-bottom: 15px !important;
}
.form-input, .form-textarea {
width: 100% !important;
max-width: 100% !important;

View File

@@ -22,18 +22,9 @@ 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
}
})
// Simple detection: if the URL contains a date+hash pattern, it's likely an AI post
const isAiPost = !pageContext.isTopPage && pageContext.rkey &&
/^\d{4}-\d{2}-\d{2}-[a-f0-9]{8}$/.test(pageContext.rkey)
// Environment-based feature flags
const ENABLE_TEST_UI = import.meta.env.VITE_ENABLE_TEST_UI === 'true'
@@ -48,7 +39,7 @@ export default function App() {
const records = await agent.api.com.atproto.repo.listRecords({
repo: user.did,
collection: 'ai.syui.log.chat',
limit: 100
limit: 50
})
// Group questions and answers together
@@ -83,8 +74,8 @@ export default function App() {
}
})
// Sort by creation time (oldest first) - for chronological conversation flow
chatPairs.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))
// Sort by creation time (newest first)
chatPairs.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
setUserChatRecords(chatPairs)
} catch (error) {
@@ -118,14 +109,6 @@ 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

View File

@@ -115,48 +115,6 @@ export const atproto = {
// Use Agent's putRecord method instead of direct fetch
return await agent.com.atproto.repo.putRecord(record)
},
// Find all records for a specific post by paginating through all records
async findRecordsForPost(pds, repo, collection, targetRkey) {
let cursor = null
let allMatchingRecords = []
let pageCount = 0
const maxPages = 50 // Safety limit to prevent infinite loops
do {
pageCount++
if (pageCount > maxPages) {
console.warn(`Reached max pages (${maxPages}) while searching for ${targetRkey}`)
break
}
const result = await this.getRecords(pds, repo, collection, 100, cursor)
// Filter records that match the target post
const matchingRecords = result.records.filter(record => {
const postUrl = record.value?.post?.url
if (!postUrl) return false
try {
// Extract rkey from URL
const recordRkey = new URL(postUrl).pathname.split('/').pop()?.replace(/\.html$/, '')
return recordRkey === targetRkey
} catch {
return false
}
})
allMatchingRecords.push(...matchingRecords)
cursor = result.cursor
// Optional: Stop early if we found some records (uncomment if desired)
// if (allMatchingRecords.length > 0) break
} while (cursor)
console.log(`Found ${allMatchingRecords.length} records for ${targetRkey} after searching ${pageCount} pages`)
return allMatchingRecords
}
}
@@ -256,53 +214,6 @@ export const collections = {
return records
},
// Find chat records for a specific post using pagination
async getChatForPost(pds, repo, collection, targetRkey) {
const cacheKey = dataCache.generateKey('chatForPost', pds, repo, collection, targetRkey)
const cached = dataCache.get(cacheKey)
if (cached) return cached
const records = await atproto.findRecordsForPost(pds, repo, `${collection}.chat`, targetRkey)
// Process into chat pairs like the original getChat function
const chatPairs = []
const recordMap = new Map()
// First pass: organize records by base rkey
records.forEach(record => {
const rkey = record.uri.split('/').pop()
const baseRkey = rkey.replace('-answer', '')
if (!recordMap.has(baseRkey)) {
recordMap.set(baseRkey, { question: null, answer: null })
}
if (record.value.type === 'question') {
recordMap.get(baseRkey).question = record
} else if (record.value.type === 'answer') {
recordMap.get(baseRkey).answer = record
}
})
// Second pass: create chat pairs
recordMap.forEach((pair, rkey) => {
if (pair.question) {
chatPairs.push({
rkey,
question: pair.question,
answer: pair.answer,
createdAt: pair.question.value.createdAt
})
}
})
// Sort by creation time (oldest first) - for chronological conversation flow
chatPairs.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))
dataCache.set(cacheKey, chatPairs)
return chatPairs
},
// 投稿後にキャッシュを無効化
invalidateCache(collection) {
dataCache.invalidatePattern(collection)

View File

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

View File

@@ -1,7 +1,6 @@
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
@@ -64,7 +63,7 @@ export default function AvatarTest() {
setTestResults(results)
} catch (error) {
logger.error('Test failed:', error)
console.error('Test failed:', error)
} finally {
setLoading(false)
}
@@ -79,7 +78,7 @@ export default function AvatarTest() {
batchResults: Object.fromEntries(avatarMap)
}))
} catch (error) {
logger.error('Batch test failed:', error)
console.error('Batch test failed:', error)
} finally {
setLoading(false)
}
@@ -95,7 +94,7 @@ export default function AvatarTest() {
prefetchResult: cachedAvatar
}))
} catch (error) {
logger.error('Prefetch test failed:', error)
console.error('Prefetch test failed:', error)
} finally {
setLoading(false)
}

View File

@@ -25,24 +25,6 @@ function getCorrectWebUrl(avatarUrl) {
export default function ChatRecordList({ chatPairs, chatHasMore, onLoadMoreChat, apiConfig, user = null, agent = null, onRecordDeleted = null }) {
const [expandedRecords, setExpandedRecords] = useState(new Set())
// Sort chat pairs by creation time (oldest first) for chronological conversation flow
const sortedChatPairs = Array.isArray(chatPairs)
? [...chatPairs].sort((a, b) => {
const dateA = new Date(a.createdAt)
const dateB = new Date(b.createdAt)
// If creation times are the same, sort by URI (which contains sequence info)
if (dateA.getTime() === dateB.getTime()) {
const uriA = a.question?.uri || ''
const uriB = b.question?.uri || ''
return uriA.localeCompare(uriB)
}
return dateA - dateB
})
: []
const toggleJsonView = (key) => {
const newExpanded = new Set(expandedRecords)
if (newExpanded.has(key)) {
@@ -53,7 +35,7 @@ export default function ChatRecordList({ chatPairs, chatHasMore, onLoadMoreChat,
setExpandedRecords(newExpanded)
}
if (!sortedChatPairs || sortedChatPairs.length === 0) {
if (!chatPairs || chatPairs.length === 0) {
return (
<section>
<p>チャット履歴がありません</p>
@@ -102,7 +84,7 @@ export default function ChatRecordList({ chatPairs, chatHasMore, onLoadMoreChat,
return (
<section>
{sortedChatPairs.map((chatPair, i) => (
{chatPairs.map((chatPair, i) => (
<div key={chatPair.rkey} className="chat-conversation">
{/* Question */}
{chatPair.question && (

View File

@@ -1,7 +1,6 @@
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('')
@@ -80,7 +79,7 @@ const ProfileForm = ({ user, agent, apiConfig, onProfilePosted }) => {
}
} catch (err) {
logger.error('Failed to create profile:', err)
console.error('Failed to create profile:', err)
setError(err.message || 'プロフィールの作成に失敗しました')
} finally {
setPosting(false)

View File

@@ -125,6 +125,18 @@ export default function RecordList({ title, records, apiConfig, showTitle = true
</div>
</div>
<div className="record-meta">
{record.value.post?.url && (
<a
href={record.value.post.url}
target="_blank"
rel="noopener noreferrer"
className="record-url"
>
{record.value.post.url}
</a>
)}
</div>
{expandedRecords.has(i) && (
<div className="json-display">

View File

@@ -4,105 +4,94 @@ import ChatRecordList from './ChatRecordList.jsx'
import ProfileRecordList from './ProfileRecordList.jsx'
import LoadingSkeleton from './LoadingSkeleton.jsx'
import { logger } from '../utils/logger.js'
import { collections } from '../api/atproto.js'
import { getApiConfig } from '../utils/pds.js'
import { env } from '../config/env.js'
export default function RecordTabs({ langRecords, commentRecords, userComments, chatRecords, chatHasMore, onLoadMoreChat, userChatRecords, userChatLoading, baseRecords, apiConfig, pageContext, user = null, agent = null, onRecordDeleted = null }) {
// State for page-specific chat records
const [pageSpecificChatRecords, setPageSpecificChatRecords] = useState([])
const [pageSpecificLoading, setPageSpecificLoading] = useState(false)
// Simple detection: if the URL contains a date+hash pattern, it's likely an AI post
const isAiPost = !pageContext.isTopPage && pageContext.rkey &&
/^\d{4}-\d{2}-\d{2}-[a-f0-9]{8}$/.test(pageContext.rkey)
// 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
const [activeTab, setActiveTab] = useState(isAiPost ? 'collection' : 'profiles')
try {
const recordRkey = new URL(recordUrl).pathname.split('/').pop()?.replace(/\.html$/, '')
return recordRkey === pageContext.rkey
} catch {
return false
}
})
const [activeTab, setActiveTab] = useState(
isAiPost ? 'collection' : (pageContext.isTopPage ? 'profiles' : 'users')
)
// Fetch page-specific chat records for individual article pages
// Monitor activeTab changes
useEffect(() => {
if (!pageContext.isTopPage && pageContext.rkey) {
const fetchPageSpecificChats = async () => {
setPageSpecificLoading(true)
try {
const apiConfig = getApiConfig(`https://${env.pds}`)
const { atproto } = await import('../api/atproto.js')
const did = await atproto.getDid(env.pds, env.admin)
logger.log('RecordTabs: activeTab changed to', activeTab)
}, [activeTab])
const records = await collections.getChatForPost(
apiConfig.pds,
did,
env.collection,
pageContext.rkey
)
setPageSpecificChatRecords(records)
} catch (error) {
setPageSpecificChatRecords([])
} finally {
setPageSpecificLoading(false)
}
}
fetchPageSpecificChats()
} else {
setPageSpecificChatRecords([])
}
}, [pageContext.isTopPage, pageContext.rkey])
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 recordsArray.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
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) {
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
}
}
// Filter chat records (which are already processed into pairs)
// Special filter for chat records (which are already processed into pairs)
const filterChatRecords = (chatPairs) => {
// Ensure chatPairs is an array
const chatArray = Array.isArray(chatPairs) ? chatPairs : []
logger.log('filterChatRecords called:', {
isTopPage: pageContext.isTopPage,
rkey: pageContext.rkey,
chatPairsLength: chatArray.length,
chatPairsType: typeof chatPairs,
isArray: Array.isArray(chatPairs)
})
if (pageContext.isTopPage) {
// Top page: show latest 3 pairs
return chatArray.slice(0, 3)
const result = chatArray.slice(0, 3)
logger.log('Top page: returning', result.length, 'pairs')
return result
} else {
// Individual page: show pairs matching the URL (compare path only, ignore domain)
const filtered = chatArray.filter(chatPair => {
const recordUrl = chatPair.question?.value?.post?.url
if (!recordUrl) {
logger.log('No recordUrl for chatPair:', chatPair)
return false
}
@@ -111,24 +100,44 @@ export default function RecordTabs({ langRecords, commentRecords, userComments,
const recordPath = new URL(recordUrl).pathname
const recordRkey = recordPath.split('/').pop()?.replace(/\.html$/, '')
logger.log('Comparing:', { recordRkey, pageRkey: pageContext.rkey, recordUrl })
// Compare with current page rkey
return recordRkey === pageContext.rkey
const matches = recordRkey === pageContext.rkey
if (matches) {
logger.log('Found matching chat pair!')
}
return matches
} catch (error) {
logger.log('Error processing recordUrl:', recordUrl, error)
return false
}
})
logger.log('Individual page: returning', filtered.length, 'filtered pairs')
return filtered
}
}
// Apply filters to all record types
const filteredLangRecords = filterRecords(Array.isArray(langRecords) ? langRecords : [])
logger.log('RecordTabs: About to filter commentRecords:', commentRecords?.length || 0, commentRecords)
const filteredCommentRecords = filterRecords(Array.isArray(commentRecords) ? commentRecords : [])
logger.log('RecordTabs: After filtering commentRecords:', filteredCommentRecords.length, filteredCommentRecords)
const filteredUserComments = filterRecords(Array.isArray(userComments) ? userComments : [])
const filteredChatRecords = filterChatRecords(Array.isArray(chatRecords) ? chatRecords : [])
const filteredBaseRecords = filterRecords(Array.isArray(baseRecords) ? baseRecords : [])
logger.log('RecordTabs: filtered results:')
logger.log(' - filteredCommentRecords:', filteredCommentRecords.length, filteredCommentRecords)
logger.log(' - filteredLangRecords:', filteredLangRecords.length)
logger.log(' - filteredUserComments:', filteredUserComments.length)
logger.log(' - pageContext:', pageContext)
logger.log('RecordTabs: TAB RENDER VALUES:')
logger.log(' - filteredCommentRecords.length for tab:', filteredCommentRecords.length)
logger.log(' - commentRecords input:', commentRecords?.length || 0)
// Filter profile records from baseRecords
const profileRecords = (Array.isArray(baseRecords) ? baseRecords : []).filter(record => record.value?.type === 'profile')
const sortedProfileRecords = profileRecords.sort((a, b) => {
@@ -144,7 +153,10 @@ export default function RecordTabs({ langRecords, commentRecords, userComments,
<div className="tab-header">
<button
className={`tab-btn ${activeTab === 'profiles' ? 'active' : ''}`}
onClick={() => setActiveTab('profiles')}
onClick={() => {
logger.log('RecordTabs: Profiles tab clicked')
setActiveTab('profiles')
}}
>
about ({filteredProfileRecords.length})
</button>
@@ -156,9 +168,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' : ''}`}
@@ -207,33 +225,19 @@ export default function RecordTabs({ langRecords, commentRecords, userComments,
)
)}
{activeTab === 'collection' && (
(userChatLoading || pageSpecificLoading) ? (
userChatLoading ? (
<LoadingSkeleton count={2} showTitle={true} />
) : (() => {
const chatPairsToUse = !pageContext.isTopPage && pageSpecificChatRecords.length > 0
? pageSpecificChatRecords
: (filteredChatRecords.length > 0 ? filteredChatRecords : (Array.isArray(userChatRecords) ? userChatRecords : []))
return (
) : (
<ChatRecordList
chatPairs={chatPairsToUse}
chatHasMore={
!pageContext.isTopPage && pageSpecificChatRecords.length > 0
? false // Page-specific records don't use pagination
: (filteredChatRecords.length > 0 ? chatHasMore : false)
}
onLoadMoreChat={
!pageContext.isTopPage && pageSpecificChatRecords.length > 0
? null // Page-specific records don't use pagination
: (filteredChatRecords.length > 0 ? onLoadMoreChat : null)
}
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}
onRecordDeleted={onRecordDeleted}
/>
)
})()
)}
{activeTab === 'users' && !isAiPost && (
!userComments ? (

View File

@@ -1,7 +1,6 @@
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('')
@@ -27,7 +26,7 @@ export default function UserLookup() {
config: apiConfig
})
} catch (error) {
logger.error('User lookup failed:', error)
console.error('User lookup failed:', error)
setUserInfo({ error: error.message })
} finally {
setLoading(false)

View File

@@ -48,7 +48,7 @@ export function useAdminData() {
logger.error('getComment error:', err)
throw err
}),
collections.getChat(apiConfig.pds, did, env.collection, 100).catch(err => {
collections.getChat(apiConfig.pds, did, env.collection, 10).catch(err => {
logger.error('getChat error:', err)
throw err
})
@@ -98,19 +98,24 @@ export function useAdminData() {
}
})
// Sort by creation time (oldest first) - for chronological conversation flow
chatPairs.sort((a, b) => {
const dateA = new Date(a.createdAt)
const dateB = new Date(b.createdAt)
return dateA - dateB
})
// Sort by creation time (newest first)
chatPairs.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
logger.log('useAdminData: raw chat records:', chat.length)
logger.log('useAdminData: processed chat pairs:', chatPairs.length, chatPairs)
logger.log('useAdminData: setting state data:')
logger.log(' - records:', records.length)
logger.log(' - langRecords:', lang.length)
logger.log(' - commentRecords:', comment.length, comment)
logger.log(' - chatRecords:', chatPairs.length)
setAdminData({ did, profile, records, apiConfig })
setLangRecords(lang)
setCommentRecords(comment)
setChatRecords(chatPairs)
} catch (err) {
// Log the actual error for debugging
logger.error('useAdminData: Error in loadAdminData:', err)
setError('silent_failure')
} finally {
setLoading(false)
@@ -123,7 +128,7 @@ export function useAdminData() {
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, 100, chatCursor)
const chatResult = await collections.getChat(apiConfig.pds, did, env.collection, 10, chatCursor)
const newChatRecords = chatResult.records || chatResult
const newCursor = chatResult.cursor || null
@@ -163,8 +168,8 @@ export function useAdminData() {
}
})
// Sort new pairs by creation time (oldest first) - for chronological conversation flow
newChatPairs.sort((a, b) => new Date(a.createdAt) - new Date(b.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])

View File

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

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect } from 'react'
export function usePageContext() {
const [pageContext, setPageContext] = useState({

View File

@@ -67,8 +67,8 @@ export function useUserData(adminData) {
}
})
// Sort by creation time (oldest first) - consistent with other components
chatPairs.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt))
// Sort by creation time (newest first)
chatPairs.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))
logger.log('useUserData: processed chatPairs:', chatPairs.length, chatPairs)
setChatRecords(chatPairs)

View File

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

View File

@@ -1,12 +0,0 @@
<!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>

View File

@@ -1,27 +0,0 @@
{
"name": "pds-browser",
"version": "0.3.4",
"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"
}
}

View File

@@ -1,128 +0,0 @@
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;
}

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