Compare commits

..

9 Commits

Author SHA1 Message Date
a3b7ea76c9 add chat content 2026-01-20 18:53:06 +09:00
f6c4ceaa5d fix 2026-01-20 18:47:52 +09:00
a77dde0366 test ai chat mcp 2026-01-20 18:26:10 +09:00
1d3aa51fb6 test ai chat 2026-01-20 17:32:27 +09:00
6f5290753d test scheme check 2026-01-19 00:00:54 +09:00
e8cf46465f fix date format 2026-01-18 22:08:50 +09:00
676c8942de fix bsky.chat icon 2026-01-18 22:06:16 +09:00
623fe61468 fix loading 2026-01-18 22:00:48 +09:00
ce00222537 fix oauth config 2026-01-18 20:43:18 +09:00
48 changed files with 4462 additions and 62 deletions

12
.env.example Normal file
View File

@@ -0,0 +1,12 @@
# LMS Translation API
TRANSLATE_URL=http://127.0.0.1:1234/v1
TRANSLATE_MODEL=plamo-2-translate
# Chat API
CHAT_URL=http://127.0.0.1:1234/v1
CHAT_MODEL=gpt-oss-20b
# CHAT_MAX_TOKENS=2048
# Character/system prompt (choose one)
# CHAT_SYSTEM="You are ai, a friendly AI assistant."
# CHAT_SYSTEM_FILE=./character.txt

36
.github/workflows/cf-pages.yml vendored Normal file
View File

@@ -0,0 +1,36 @@
name: Deploy to Cloudflare Pages
on:
push:
branches:
- main
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
- name: Install dependencies
run: npm install
- name: Build content from ATProto
run: npm run build
- name: Deploy to Cloudflare Pages
uses: cloudflare/pages-action@v1
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
projectName: ${{ secrets.CLOUDFLARE_PROJECT_NAME }}
directory: dist
gitHubToken: ${{ secrets.GITHUB_TOKEN }}

25
Cargo.toml Normal file
View File

@@ -0,0 +1,25 @@
[package]
name = "ailog"
version = "0.0.1"
edition = "2021"
description = "ATProto blog CLI"
authors = ["syui"]
homepage = "https://syui.ai"
repository = "https://git.syui.ai/ai/log"
[[bin]]
name = "ailog"
path = "src/main.rs"
[dependencies]
clap = { version = "4.5", features = ["derive"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
anyhow = "1.0"
dirs = "5.0"
chrono = { version = "0.4", features = ["serde"] }
rand = "0.8"
dotenvy = "0.15"
rustyline = "15"

View File

@@ -0,0 +1,43 @@
{
"lexicon": 1,
"id": "ai.syui.log.chat",
"defs": {
"main": {
"type": "record",
"description": "Record containing a chat message in a conversation.",
"key": "tid",
"record": {
"type": "object",
"required": ["content", "author", "createdAt"],
"properties": {
"content": {
"type": "string",
"maxLength": 100000,
"maxGraphemes": 10000,
"description": "The content of the message."
},
"author": {
"type": "string",
"format": "did",
"description": "DID of the message author."
},
"root": {
"type": "string",
"format": "at-uri",
"description": "AT-URI of the root message in the thread."
},
"parent": {
"type": "string",
"format": "at-uri",
"description": "AT-URI of the parent message being replied to."
},
"createdAt": {
"type": "string",
"format": "datetime",
"description": "Client-declared timestamp when this message was created."
}
}
}
}
}
}

View File

@@ -0,0 +1,68 @@
{
"lexicon": 1,
"id": "ai.syui.log.post",
"defs": {
"main": {
"type": "record",
"description": "Record containing a blog post.",
"key": "tid",
"record": {
"type": "object",
"required": ["title", "content", "createdAt"],
"properties": {
"title": {
"type": "string",
"maxLength": 3000,
"maxGraphemes": 300,
"description": "The title of the post."
},
"content": {
"type": "string",
"maxLength": 1000000,
"maxGraphemes": 100000,
"description": "The content of the post (markdown)."
},
"createdAt": {
"type": "string",
"format": "datetime",
"description": "Client-declared timestamp when this post was originally created."
},
"lang": {
"type": "string",
"maxLength": 10,
"description": "Language code of the original content (e.g., 'ja', 'en')."
},
"translations": {
"type": "ref",
"ref": "#translationMap",
"description": "Translations of the post in other languages."
}
}
}
},
"translationMap": {
"type": "object",
"description": "Map of language codes to translations.",
"properties": {
"en": { "type": "ref", "ref": "#translation" },
"ja": { "type": "ref", "ref": "#translation" }
}
},
"translation": {
"type": "object",
"description": "A translation of a post.",
"properties": {
"title": {
"type": "string",
"maxLength": 3000,
"maxGraphemes": 300
},
"content": {
"type": "string",
"maxLength": 1000000,
"maxGraphemes": 100000
}
}
}
}
}

View File

@@ -9,9 +9,10 @@
}, },
"dependencies": { "dependencies": {
"@atproto/api": "^0.15.12", "@atproto/api": "^0.15.12",
"@atproto/lexicon": "^0.6.0",
"@atproto/oauth-client-browser": "^0.3.19", "@atproto/oauth-client-browser": "^0.3.19",
"marked": "^15.0.6", "highlight.js": "^11.11.1",
"highlight.js": "^11.11.1" "marked": "^15.0.6"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.7.3", "typescript": "^5.7.3",

View File

@@ -1,9 +1,10 @@
{ {
"title": "ailog", "title": "syui.ai",
"handle": "syui.ai", "did": "did:plc:vzsvtbtbnwn22xjqhcu3vd6y",
"handle": "syui.syui.ai",
"collection": "ai.syui.log.post", "collection": "ai.syui.log.post",
"network": "bsky.social", "network": "syu.is",
"color": "#EF454A", "color": "#EF454A",
"siteUrl": "https://syui.github.io", "siteUrl": "https://syui.ai",
"oauth": false "oauth": true
} }

View File

@@ -0,0 +1,12 @@
{
"uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat/d3dmreieiynnd",
"cid": "bafyreid3dmreieiynnd",
"value": {
"$type": "ai.syui.log.chat",
"author": "did:plc:6qyecktefllvenje24fcxnie",
"content": "ailogのコンセプトね。\n\n**「データはATProtoに、サーバーには置かない」**\n\nこれが核心だよ。\n\n従来のブログは、サーバーのDBに記事を保存する。でもailogは違う。\n\n```\nユーザーのPDSATProto← 記事データ\n ↓\n ailogビューア→ 表示するだけ\n```\n\nailogは**ビューア**なの。データの保管庫じゃない。\n\nつまり、あなたのブログ記事は**あなたのPDS**に存在する。サービスが消えても、データは残る。これがATProtoの分散思想だね。\n\n誰でも`/@handle`でアクセスできる。登録不要。ATProtoアカウントがあれば、それがブログになる。\n\n面白いでしょ何か気になることある",
"createdAt": "2026-01-20T09:48:37.328Z",
"parent": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/wigv2qnon7pmg",
"root": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/cav7q75r73jui"
}
}

View File

@@ -0,0 +1,12 @@
{
"uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat/evknoqtr7uyey",
"cid": "bafyreievknoqtr7uyey",
"value": {
"$type": "ai.syui.log.chat",
"author": "did:plc:6qyecktefllvenje24fcxnie",
"content": "開発者の活動と宣伝かぁ。\n\nこれって、どっちの話\n\n1. **ailogの開発者**syuiの活動を紹介したい\n2. **一般的に**開発者がどう活動・宣伝すべきか\n3. **ailogを使って**自分の活動を宣伝する方法\n\nどれが気になる",
"createdAt": "2026-01-20T09:49:05.243Z",
"parent": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/qbuquaswgxo36",
"root": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/qbuquaswgxo36"
}
}

View File

@@ -0,0 +1,5 @@
[
"d3dmreieiynnd",
"evknoqtr7uyey",
"kr5ig7vlgl276"
]

View File

@@ -0,0 +1,12 @@
{
"uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat/kr5ig7vlgl276",
"cid": "bafyreikr5ig7vlgl276",
"value": {
"$type": "ai.syui.log.chat",
"author": "did:plc:6qyecktefllvenje24fcxnie",
"content": "なるほどね。自己分析できてるのがいい。\n\n**向いてないことを無理にやっても続かない**。それは正しい。\n\n私の見立てだと\n\n**1番が一番いい**と思う。\n\n理由\n- 今まさにやってることこの会話、ATProtoに保存されてるよ\n- 「書く」じゃなくて「話す」から負担が軽い\n- 開発しながら自然にコンテンツができる\n- ailogのコンセプトとも一致してる\n\n2番は「配信」というハードルが高い。準備、時間拘束、編集...続かない可能性。\n\n3番は自分で「向いてない」って言ってる。やめとこ。\n\n**提案**\nAIとの会話開発メモ、設計相談、デバッグをそのまま公開ブログにする。これ、ailogでできるよね\n\nどう思う",
"createdAt": "2026-01-20T09:49:39.262Z",
"parent": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/p6mxoi2x2ooz6",
"root": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/qbuquaswgxo36"
}
}

View File

@@ -0,0 +1,17 @@
{
"cid": "bafyreigknaeiqn7txi3of25k7o3jnxdcft4pmcqi2sbfkkfefriqm6tyoe",
"uri": "at://did:plc:6qyecktefllvenje24fcxnie/app.bsky.actor.profile/self",
"value": {
"$type": "app.bsky.actor.profile",
"avatar": {
"$type": "blob",
"mimeType": "image/jpeg",
"ref": {
"$link": "bafkreigo3ucp32carhbn3chfc3hlf6i7f4rplojc76iylihzpifyexi24y"
},
"size": 375259
},
"description": "https://syu.is/syui",
"displayName": "ai"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

View File

@@ -0,0 +1,16 @@
{
"collections": [
"ai.syui.card.admin",
"ai.syui.card.user",
"ai.syui.log.chat",
"ai.syui.rse.admin",
"ai.syui.rse.user",
"app.bsky.actor.profile",
"app.bsky.feed.generator",
"app.bsky.feed.post",
"app.bsky.graph.follow",
"app.bsky.graph.verification"
],
"did": "did:plc:6qyecktefllvenje24fcxnie",
"handle": "ai.syui.ai"
}

View File

@@ -0,0 +1,5 @@
[
"wigv2qnon7pmg",
"qbuquaswgxo36",
"p6mxoi2x2ooz6"
]

View File

@@ -0,0 +1,12 @@
{
"uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/p6mxoi2x2ooz6",
"cid": "bafyreip6mxoi2x2ooz6",
"value": {
"$type": "ai.syui.log.chat",
"author": "did:plc:vzsvtbtbnwn22xjqhcu3vd6y",
"content": "開発者の日々の活動を投稿したり配信したりするのは大切だと思う。\nせっかくなら多くの人に使ってもらいたいけど、何をやってるのかわからないと判断できないと思う。\nでも、その活動をどうやって紹介すればいいのか悩んでいます。\n1. AIとの会話を自動で投稿(ブログ書く手間が省ける)\n2. AIと話しながら配信(開発状況もわかる)\n3. twitter, note, youtubeで他の人と同じことをやってみる\n(向いていない、すぐ更新しなくなるのは目に見えている。書くことも話すこともほとんどない)",
"createdAt": "2026-01-20T09:49:39.262Z",
"parent": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat/evknoqtr7uyey",
"root": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/qbuquaswgxo36"
}
}

View File

@@ -0,0 +1,10 @@
{
"uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/qbuquaswgxo36",
"cid": "bafyreiqbuquaswgxo36",
"value": {
"$type": "ai.syui.log.chat",
"author": "did:plc:vzsvtbtbnwn22xjqhcu3vd6y",
"content": "開発者の活動と宣伝",
"createdAt": "2026-01-20T09:49:05.242Z"
}
}

View File

@@ -0,0 +1,12 @@
{
"uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/wigv2qnon7pmg",
"cid": "bafyreiwigv2qnon7pmg",
"value": {
"$type": "ai.syui.log.chat",
"author": "did:plc:vzsvtbtbnwn22xjqhcu3vd6y",
"content": "ailogのコンセプトを短く紹介してみて。",
"createdAt": "2026-01-20T09:48:37.328Z",
"parent": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat/3ar343fz5oop3",
"root": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/cav7q75r73jui"
}
}

View File

@@ -0,0 +1,16 @@
{
"cid": "bafyreicsjh5jf2yqmm4rfi2oha6zcafw57s3kz7w4iqw4eoh7vf6bwfoyu",
"uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.post/3mchqlshygs2s",
"value": {
"$type": "ai.syui.log.post",
"content": "## ailogとは\n\natprotoと連携するサイトジェネレータ。\n\n## ailogの使い方\n\n```sh\n$ git clone https://git.syui.ai/ai/log\n$ cd log\n$ cat public/config.json\n{\n \"title\": \"syui.ai\",\n \"handle\": \"syui.syui.ai\",\n \"collection\": \"ai.syui.log.post\",\n \"network\": \"syu.is\",\n \"color\": \"#0066cc\",\n \"siteUrl\": \"https://syui.ai\"\n}\n---\n$ npm run dev\n```\n\n## ailogのコンセプト\n\n1. at-browserを基本にする\n2. atproto oauthでログインする\n3. ログインしたアカウントで記事をポストする",
"createdAt": "2026-01-18T08:31:52.715Z",
"title": "ailogを作り直した",
"translations": {
"en": {
"content": "## What is ailog?\n\nA site generator that integrates with atproto.\n\n## How to use ailog\n\n```sh\n$ git clone https://git.syui.ai/ai/log\n$ cd log\n$ cat public/config.json\n{\n \"title\": \"syui.ai\",\n \"handle\": \"syui.syui.ai\",\n \"collection\": \"ai.syui.log.post\",\n \"network\": \"syu.is\",\n \"color\": \"#0066cc\",\n \"siteUrl\": \"https://syui.ai\"\n}\n---\n$ npm run dev\n```\n\n## ailog's concept\n\n1. Based on at-browser as its foundation\n2. Logs in via atproto oauth\n3. Allows users to post articles using their logged-in account",
"title": "recreated ailog"
}
}
}
}

View File

@@ -0,0 +1,3 @@
[
"3mchqlshygs2s"
]

View File

@@ -0,0 +1,18 @@
{
"cid": "bafyreihlch2vdee6wpydo2bwap7nyzszjz6focbtxikz7zljcejxz27npy",
"uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/app.bsky.actor.profile/self",
"value": {
"$type": "app.bsky.actor.profile",
"avatar": {
"$type": "blob",
"mimeType": "image/jpeg",
"ref": {
"$link": "bafkreigta4pf5h7uvx6jpfcm3d6aeq4g3qpsiqjdoeytnutwp6vwc2yo7u"
},
"size": 166370
},
"createdAt": "2025-09-19T06:17:42Z",
"description": "",
"displayName": "syui"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

View File

@@ -0,0 +1,14 @@
{
"collections": [
"ai.syui.log.chat",
"ai.syui.log.post",
"app.bsky.actor.profile",
"app.bsky.feed.post",
"app.bsky.feed.repost",
"app.bsky.graph.follow",
"chat.bsky.actor.declaration",
"com.atproto.lexicon.schema"
],
"did": "did:plc:vzsvtbtbnwn22xjqhcu3vd6y",
"handle": "syui.syui.ai"
}

383
readme.md
View File

@@ -11,3 +11,386 @@ $ cat public/config.json
$ npm run dev $ npm run dev
``` ```
## oauth
Use ATProto OAuth to login from the browser and create, edit, or delete posts.
### Setup
#### 1. Edit client-metadata.json
Modify `public/client-metadata.json` with your own domain:
```json
{
"client_id": "https://example.com/client-metadata.json",
"client_name": "example.com",
"client_uri": "https://example.com",
"redirect_uris": ["https://example.com/"],
"scope": "atproto transition:generic",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"application_type": "web",
"token_endpoint_auth_method": "none",
"dpop_bound_access_tokens": true
}
```
**Required changes:**
| Field | Description |
|-------|-------------|
| `client_id` | URL of this file. Must be `https://yourdomain.com/client-metadata.json` |
| `client_name` | App name (shown on auth screen) |
| `client_uri` | Your site URL |
| `redirect_uris` | Redirect URL after OAuth. Use your site's root URL |
#### 2. Deploy the file
`client-metadata.json` must be publicly accessible at:
```
https://yourdomain.com/client-metadata.json
```
The ATProto PDS fetches this file during authentication, so it **must be accessible via public URL**.
#### 3. Local development
No configuration needed for local development (localhost/127.0.0.1). The code automatically uses ATProto's loopback client ID:
```
http://localhost?redirect_uri=http://127.0.0.1:5173/&scope=atproto%20transition%3Ageneric
```
#### 4. Network configuration
To support multiple PDS servers, define networks in `public/network.json`:
```json
{
"bsky.social": {
"bsky": "https://bsky.social",
"plc": "https://plc.directory"
},
"syu.is": {
"bsky": "https://bsky.syu.is",
"plc": "https://plc.syu.is",
"web": "https://syu.is"
}
}
```
The appropriate PDS is automatically selected based on the handle's domain.
### Troubleshooting
- **Auth error**: Verify `client_id` matches the actual file URL
- **Redirect error**: Verify `redirect_uris` matches your site URL
- **CORS error**: Verify `client-metadata.json` is served with correct Content-Type
## cli
```sh
$ cargo build
$ ./target/debug/ailog
```
### login (l)
login to atproto pds.
```sh
$ ailog login <handle> -p <password> [-s <server>]
$ ailog l user.bsky.social -p mypassword
$ ailog l user.syu.is -p mypassword -s syu.is
```
### post (p)
post a record to collection.
```sh
$ ailog post <file> -c <collection> [-r <rkey>]
$ ailog p ./post.json -c ai.syui.log.post
$ ailog p ./post.json -c ai.syui.log.post -r 3abc123
```
### get (g)
get records from collection.
```sh
$ ailog get -c <collection> [-l <limit>]
$ ailog g -c ai.syui.log.post
$ ailog g -c ai.syui.log.post -l 20
```
### delete (d)
delete a record from collection.
```sh
$ ailog delete -c <collection> -r <rkey>
$ ailog d -c ai.syui.log.post -r 3abc123
```
### sync (s)
sync pds data to local content directory.
```sh
$ ailog sync [-o <output>]
$ ailog s
$ ailog s -o ./public/content
```
### lexicon
update lexicon schema.
```sh
$ ailog lexicon <file>
$ ailog lexicon ./lexicons/ai.syui.log.post.json
```
```sh
$ ailog did syui.ai
did:plc:uqzpqmrjnptsxezjx4xuh2mn
```
```txt
_lexicon.log.syui.ai txt "did=did:plc:uqzpqmrjnptsxezjx4xuh2mn"
```
### gen
generate lexicon code from atproto lexicon json files.
```sh
$ ailog gen [-i <input>] [-o <output>]
$ ailog gen
$ ailog gen -i ./repos/atproto/lexicons -o ./src/lexicons
```
### lang
translate content files using lms.
```sh
$ ailog lang <input> [-f <from>] [-t <to>]
$ ailog lang ./post.json
$ ailog lang ./public/content -f ja -t en
```
requires `.env`:
```
TRANSLATE_URL=http://127.0.0.1:1234/v1
TRANSLATE_MODEL=plamo-2-translate
```
## Lexicon Validation (Browser)
AT-Browser has a "Validate" button on record detail pages to validate records against their lexicon schema.
### How it works
```
NSID: app.bsky.actor.profile
1. Parse NSID → authority: actor.bsky.app
2. DNS TXT lookup: _lexicon.actor.bsky.app
→ did=did:plc:xxx
3. Resolve DID → PDS endpoint
4. Fetch lexicon from PDS:
com.atproto.repo.getRecord
- repo: did:plc:xxx
- collection: com.atproto.lexicon.schema
- rkey: app.bsky.actor.profile
5. Validate record with @atproto/lexicon
```
### DNS TXT Record Setup
To publish your own lexicon, set a DNS TXT record:
```
_lexicon.log.syui.ai TXT "did=did:plc:uqzpqmrjnptsxezjx4xuh2mn"
```
Then create the lexicon record in your repo under `com.atproto.lexicon.schema` collection.
### Browser-compatible DNS lookup
Uses Cloudflare DNS-over-HTTPS (DoH) for browser compatibility:
```
https://mozilla.cloudflare-dns.com/dns-query?name=_lexicon.actor.bsky.app&type=TXT
```
### Note: com.atproto.lexicon.resolveLexicon
ATProto spec defines `com.atproto.lexicon.resolveLexicon` endpoint, but it's not yet implemented on any PDS (bsky.social, syu.is, etc.):
```sh
$ curl "https://bsky.social/xrpc/com.atproto.lexicon.resolveLexicon?nsid=app.bsky.actor.profile"
{"error":"XRPCNotSupported","message":"XRPCNotSupported"}
```
The current implementation uses the DNS-based approach instead, which works today.
### Reference
- [resolve-lexicon](https://resolve-lexicon.pages.dev/) - Browser-compatible lexicon resolver
## chat
Chat with AI bot and save conversations to ATProto.
### Setup
1. Login as user and bot:
```sh
# User login
$ ailog login user.syu.is -p <password> -s syu.is
# Bot login
$ ailog login ai.syu.is -p <password> -s syu.is --bot
```
2. Configure LLM endpoint in `.env`:
```
CHAT_URL=http://127.0.0.1:1234/v1
CHAT_MODEL=gemma-2-9b
```
3. (Optional) Set character/system prompt:
```sh
# Direct prompt
CHAT_SYSTEM="You are ai, a friendly AI assistant."
# Or load from file
CHAT_SYSTEM_FILE=./character.txt
```
### Usage
```sh
# Start a new conversation
$ ailog chat --new "hello"
# Continue the conversation
$ ailog chat "how are you?"
# Interactive mode (new session)
$ ailog chat --new
# Interactive mode (continue)
$ ailog chat
```
### Data Storage
Messages are saved locally to `public/content/{did}/ai.syui.log.chat/`:
```
public/content/
├── did:plc:xxx/ # User's messages
│ └── ai.syui.log.chat/
│ ├── index.json
│ └── {rkey}.json
└── did:plc:yyy/ # Bot's messages
└── ai.syui.log.chat/
├── index.json
└── {rkey}.json
```
### Sync & Push
```sh
# Sync bot data from PDS to local
$ ailog sync --bot
# Push local chat to PDS
$ ailog push -c ai.syui.log.chat --bot
```
### Web Display
View chat threads at `/@{handle}/at/chat`:
- `/@user.syu.is/at/chat` - Thread list (conversations started by user)
- `/@user.syu.is/at/chat/{rkey}` - Full conversation thread
### Record Schema
```json
{
"$type": "ai.syui.log.chat",
"content": "message text",
"author": "did:plc:xxx",
"createdAt": "2025-01-01T00:00:00.000Z",
"root": "at://did:plc:xxx/ai.syui.log.chat/{rkey}",
"parent": "at://did:plc:yyy/ai.syui.log.chat/{rkey}"
}
```
- `root`: First message URI in the thread (empty for conversation start)
- `parent`: Previous message URI in the thread
### Claude Code Integration (MCP)
Use Claude Code to chat and automatically save conversations.
**1. Setup MCP server:**
```sh
# Add MCP server
$ claude mcp add ailog /path/to/ailog mcp-serve
# Or with full path
$ claude mcp add ailog ~/ai/log/target/release/ailog mcp-serve
# Verify
$ claude mcp list
```
Or manually edit `~/.claude.json`:
```json
{
"mcpServers": {
"ailog": {
"command": "/path/to/ailog",
"args": ["mcp-serve"]
}
}
}
```
**2. Chat with Claude:**
```sh
$ cd ~/ai/log
$ claude
> こんにちは
# Claude:
# 1. get_character でキャラクター設定取得
# 2. キャラクター(アイ)として応答
# 3. chat_save で会話を自動保存
```
**MCP Tools:**
- `get_character` - Get AI character settings from .env
- `chat_save` - Save conversation exchange
- `chat_list` - List recent messages
- `chat_new` - Start new thread

138
src/commands/auth.rs Normal file
View File

@@ -0,0 +1,138 @@
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use super::token::{self, Session};
use crate::lexicons::{self, com_atproto_server};
#[derive(Debug, Serialize)]
struct CreateSessionRequest {
identifier: String,
password: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CreateSessionResponse {
did: String,
handle: String,
access_jwt: String,
refresh_jwt: String,
}
/// Login to ATProto PDS
pub async fn login(handle: &str, password: &str, pds: &str, is_bot: bool) -> Result<()> {
let client = reqwest::Client::new();
let url = lexicons::url(pds, &com_atproto_server::CREATE_SESSION);
let req = CreateSessionRequest {
identifier: handle.to_string(),
password: password.to_string(),
};
let account_type = if is_bot { "bot" } else { "user" };
println!("Logging in to {} as {} ({})...", pds, handle, account_type);
let res = client
.post(&url)
.json(&req)
.send()
.await
.context("Failed to send login request")?;
if !res.status().is_success() {
let status = res.status();
let body = res.text().await.unwrap_or_default();
anyhow::bail!("Login failed: {} - {}", status, body);
}
let session_res: CreateSessionResponse = res.json().await?;
let session = Session {
did: session_res.did,
handle: session_res.handle,
access_jwt: session_res.access_jwt,
refresh_jwt: session_res.refresh_jwt,
pds: Some(pds.to_string()),
};
if is_bot {
token::save_bot_session(&session)?;
} else {
token::save_session(&session)?;
}
println!("Logged in as {} ({})", session.handle, session.did);
Ok(())
}
/// Refresh access token
pub async fn refresh_session() -> Result<Session> {
let session = token::load_session()?;
let pds = session.pds.as_deref().unwrap_or("bsky.social");
let client = reqwest::Client::new();
let url = lexicons::url(pds, &com_atproto_server::REFRESH_SESSION);
let res = client
.post(&url)
.header("Authorization", format!("Bearer {}", session.refresh_jwt))
.send()
.await
.context("Failed to refresh session")?;
if !res.status().is_success() {
let status = res.status();
let body = res.text().await.unwrap_or_default();
anyhow::bail!("Refresh failed: {} - {}. Try logging in again.", status, body);
}
let new_session: CreateSessionResponse = res.json().await?;
let session = Session {
did: new_session.did,
handle: new_session.handle,
access_jwt: new_session.access_jwt,
refresh_jwt: new_session.refresh_jwt,
pds: Some(pds.to_string()),
};
token::save_session(&session)?;
Ok(session)
}
/// Refresh bot access token
pub async fn refresh_bot_session() -> Result<Session> {
let session = token::load_bot_session()?;
let pds = session.pds.as_deref().unwrap_or("bsky.social");
let client = reqwest::Client::new();
let url = lexicons::url(pds, &com_atproto_server::REFRESH_SESSION);
let res = client
.post(&url)
.header("Authorization", format!("Bearer {}", session.refresh_jwt))
.send()
.await
.context("Failed to refresh bot session")?;
if !res.status().is_success() {
let status = res.status();
let body = res.text().await.unwrap_or_default();
anyhow::bail!("Bot refresh failed: {} - {}. Try 'ailog login --bot' again.", status, body);
}
let new_session: CreateSessionResponse = res.json().await?;
let session = Session {
did: new_session.did,
handle: new_session.handle,
access_jwt: new_session.access_jwt,
refresh_jwt: new_session.refresh_jwt,
pds: Some(pds.to_string()),
};
token::save_bot_session(&session)?;
Ok(session)
}

34
src/commands/did.rs Normal file
View File

@@ -0,0 +1,34 @@
use anyhow::{Context, Result};
use serde::Deserialize;
use crate::lexicons::{self, com_atproto_identity};
#[derive(Debug, Deserialize)]
struct ResolveHandleResponse {
did: String,
}
/// Resolve handle to DID
pub async fn resolve(handle: &str, server: &str) -> Result<()> {
let client = reqwest::Client::new();
let url = format!(
"{}?handle={}",
lexicons::url(server, &com_atproto_identity::RESOLVE_HANDLE),
handle
);
let res = client.get(&url).send().await?;
if !res.status().is_success() {
let status = res.status();
let body = res.text().await.unwrap_or_default();
anyhow::bail!("Failed to resolve handle: {} - {}", status, body);
}
let result: ResolveHandleResponse = res.json().await
.context("Failed to parse response")?;
println!("{}", result.did);
Ok(())
}

265
src/commands/gen.rs Normal file
View File

@@ -0,0 +1,265 @@
use anyhow::{Context, Result};
use serde::Deserialize;
use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
#[derive(Debug, Deserialize)]
struct Lexicon {
id: String,
defs: BTreeMap<String, LexiconDef>,
}
#[derive(Debug, Deserialize)]
struct LexiconDef {
#[serde(rename = "type")]
def_type: Option<String>,
}
struct EndpointInfo {
nsid: String,
method: String, // GET or POST
}
/// Generate lexicon code from ATProto lexicon JSON files
pub fn generate(input: &str, output: &str) -> Result<()> {
let input_path = Path::new(input);
if !input_path.exists() {
anyhow::bail!("Input directory does not exist: {}", input);
}
println!("Scanning lexicons from: {}", input);
// Collect all endpoints grouped by namespace
let mut namespaces: BTreeMap<String, Vec<EndpointInfo>> = BTreeMap::new();
// Scan com/atproto directory
let atproto_path = input_path.join("com/atproto");
if atproto_path.exists() {
scan_namespace(&atproto_path, "com.atproto", &mut namespaces)?;
}
// Scan app/bsky directory
let bsky_path = input_path.join("app/bsky");
if bsky_path.exists() {
scan_namespace(&bsky_path, "app.bsky", &mut namespaces)?;
}
// Generate Rust code
let rust_code = generate_rust_code(&namespaces);
let rust_output_path = Path::new(output).join("mod.rs");
fs::create_dir_all(output)?;
fs::write(&rust_output_path, &rust_code)?;
println!("Generated Rust: {}", rust_output_path.display());
// Generate TypeScript code
let ts_output = output.replace("src/lexicons", "src/web/lexicons");
let ts_code = generate_typescript_code(&namespaces);
let ts_output_path = Path::new(&ts_output).join("index.ts");
fs::create_dir_all(&ts_output)?;
fs::write(&ts_output_path, &ts_code)?;
println!("Generated TypeScript: {}", ts_output_path.display());
println!("Total namespaces: {}", namespaces.len());
let total_endpoints: usize = namespaces.values().map(|v| v.len()).sum();
println!("Total endpoints: {}", total_endpoints);
Ok(())
}
fn scan_namespace(
base_path: &Path,
prefix: &str,
namespaces: &mut BTreeMap<String, Vec<EndpointInfo>>,
) -> Result<()> {
for entry in fs::read_dir(base_path)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
let ns_name = path.file_name()
.and_then(|n| n.to_str())
.context("Invalid directory name")?;
let full_ns = format!("{}.{}", prefix, ns_name);
let mut endpoints = Vec::new();
// Scan JSON files in this namespace
for file_entry in fs::read_dir(&path)? {
let file_entry = file_entry?;
let file_path = file_entry.path();
if file_path.extension().map(|e| e == "json").unwrap_or(false) {
if let Some(endpoint) = parse_lexicon_file(&file_path)? {
endpoints.push(endpoint);
}
}
}
if !endpoints.is_empty() {
endpoints.sort_by(|a, b| a.nsid.cmp(&b.nsid));
namespaces.insert(full_ns, endpoints);
}
}
}
Ok(())
}
fn parse_lexicon_file(path: &Path) -> Result<Option<EndpointInfo>> {
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read: {}", path.display()))?;
let lexicon: Lexicon = serde_json::from_str(&content)
.with_context(|| format!("Failed to parse: {}", path.display()))?;
// Get the main definition type
let main_def = match lexicon.defs.get("main") {
Some(def) => def,
None => return Ok(None),
};
let method = match main_def.def_type.as_deref() {
Some("query") => "GET",
Some("procedure") => "POST",
Some("subscription") => return Ok(None), // Skip websocket subscriptions
_ => return Ok(None), // Skip records, tokens, etc.
};
Ok(Some(EndpointInfo {
nsid: lexicon.id,
method: method.to_string(),
}))
}
fn generate_rust_code(namespaces: &BTreeMap<String, Vec<EndpointInfo>>) -> String {
let mut code = String::new();
// Header
code.push_str("//! Auto-generated from ATProto lexicons\n");
code.push_str("//! Run `ailog gen` to regenerate\n");
code.push_str("//! Do not edit manually\n\n");
code.push_str("#![allow(dead_code)]\n\n");
// Endpoint struct
code.push_str("#[derive(Debug, Clone, Copy)]\n");
code.push_str("pub struct Endpoint {\n");
code.push_str(" pub nsid: &'static str,\n");
code.push_str(" pub method: &'static str,\n");
code.push_str("}\n\n");
// URL helper function
code.push_str("/// Build XRPC URL for an endpoint\n");
code.push_str("pub fn url(pds: &str, endpoint: &Endpoint) -> String {\n");
code.push_str(" format!(\"https://{}/xrpc/{}\", pds, endpoint.nsid)\n");
code.push_str("}\n\n");
// Generate modules for each namespace
for (ns, endpoints) in namespaces {
// Convert namespace to module name: com.atproto.repo -> com_atproto_repo
let mod_name = ns.replace('.', "_");
code.push_str(&format!("pub mod {} {{\n", mod_name));
code.push_str(" use super::Endpoint;\n\n");
for endpoint in endpoints {
// Extract the method name from NSID: com.atproto.repo.listRecords -> LIST_RECORDS
let method_name = endpoint.nsid
.rsplit('.')
.next()
.unwrap_or(&endpoint.nsid);
// Convert camelCase to SCREAMING_SNAKE_CASE
let const_name = to_screaming_snake_case(method_name);
code.push_str(&format!(
" pub const {}: Endpoint = Endpoint {{ nsid: \"{}\", method: \"{}\" }};\n",
const_name, endpoint.nsid, endpoint.method
));
}
code.push_str("}\n\n");
}
code
}
fn generate_typescript_code(namespaces: &BTreeMap<String, Vec<EndpointInfo>>) -> String {
let mut code = String::new();
// Header
code.push_str("// Auto-generated from ATProto lexicons\n");
code.push_str("// Run `ailog gen` to regenerate\n");
code.push_str("// Do not edit manually\n\n");
// Endpoint type
code.push_str("export interface Endpoint {\n");
code.push_str(" nsid: string\n");
code.push_str(" method: 'GET' | 'POST'\n");
code.push_str("}\n\n");
// URL helper function
code.push_str("/** Build XRPC URL for an endpoint */\n");
code.push_str("export function xrpcUrl(pds: string, endpoint: Endpoint): string {\n");
code.push_str(" return `https://${pds}/xrpc/${endpoint.nsid}`\n");
code.push_str("}\n\n");
// Generate namespaces
for (ns, endpoints) in namespaces {
// Convert namespace to object name: com.atproto.repo -> comAtprotoRepo
let obj_name = to_camel_case(&ns.replace('.', "_"));
code.push_str(&format!("export const {} = {{\n", obj_name));
for endpoint in endpoints {
// Extract the method name from NSID: com.atproto.repo.listRecords -> listRecords
let method_name = endpoint.nsid
.rsplit('.')
.next()
.unwrap_or(&endpoint.nsid);
code.push_str(&format!(
" {}: {{ nsid: '{}', method: '{}' }} as Endpoint,\n",
method_name, endpoint.nsid, endpoint.method
));
}
code.push_str("} as const\n\n");
}
code
}
fn to_screaming_snake_case(s: &str) -> String {
let mut result = String::new();
for (i, c) in s.chars().enumerate() {
if c.is_uppercase() && i > 0 {
result.push('_');
}
result.push(c.to_ascii_uppercase());
}
result
}
fn to_camel_case(s: &str) -> String {
let mut result = String::new();
let mut capitalize_next = false;
for (i, c) in s.chars().enumerate() {
if c == '_' {
capitalize_next = true;
} else if capitalize_next {
result.push(c.to_ascii_uppercase());
capitalize_next = false;
} else if i == 0 {
result.push(c.to_ascii_lowercase());
} else {
result.push(c);
}
}
result
}

10
src/commands/lang.rs Normal file
View File

@@ -0,0 +1,10 @@
use anyhow::Result;
use std::path::Path;
use crate::lms;
/// Translate content files from one language to another
pub async fn translate(input: &str, from: &str, to: &str) -> Result<()> {
let path = Path::new(input);
lms::translate::run(path, from, to).await
}

6
src/commands/mod.rs Normal file
View File

@@ -0,0 +1,6 @@
pub mod auth;
pub mod token;
pub mod post;
pub mod gen;
pub mod lang;
pub mod did;

466
src/commands/post.rs Normal file
View File

@@ -0,0 +1,466 @@
use anyhow::{Context, Result};
use rand::Rng;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::fs;
use super::{auth, token};
use crate::lexicons::{self, com_atproto_repo, com_atproto_identity};
#[derive(Debug, Serialize)]
struct PutRecordRequest {
repo: String,
collection: String,
rkey: String,
record: Value,
}
#[derive(Debug, Serialize)]
struct DeleteRecordRequest {
repo: String,
collection: String,
rkey: String,
}
#[derive(Debug, Deserialize)]
struct PutRecordResponse {
uri: String,
cid: String,
}
#[derive(Debug, Deserialize)]
struct ListRecordsResponse {
records: Vec<Record>,
#[serde(default)]
#[allow(dead_code)]
cursor: Option<String>,
}
#[derive(Debug, Deserialize)]
struct Record {
uri: String,
cid: String,
value: Value,
}
/// Generate TID (timestamp-based ID)
fn generate_tid() -> String {
const CHARSET: &[u8] = b"234567abcdefghijklmnopqrstuvwxyz";
let mut rng = rand::thread_rng();
(0..13)
.map(|_| {
let idx = rng.gen_range(0..CHARSET.len());
CHARSET[idx] as char
})
.collect()
}
/// Put a record to ATProto
pub async fn put_record(file: &str, collection: &str, rkey: Option<&str>) -> Result<()> {
let session = auth::refresh_session().await?;
let pds = session.pds.as_deref().unwrap_or("bsky.social");
let content = fs::read_to_string(file)
.with_context(|| format!("Failed to read file: {}", file))?;
let record: Value = serde_json::from_str(&content)?;
let rkey = rkey.map(|s| s.to_string()).unwrap_or_else(generate_tid);
let client = reqwest::Client::new();
let url = lexicons::url(pds, &com_atproto_repo::PUT_RECORD);
let req = PutRecordRequest {
repo: session.did.clone(),
collection: collection.to_string(),
rkey: rkey.clone(),
record,
};
println!("Posting to {} with rkey: {}", collection, rkey);
println!("{}", serde_json::to_string_pretty(&req)?);
let res = client
.post(&url)
.header("Authorization", format!("Bearer {}", session.access_jwt))
.json(&req)
.send()
.await?;
if !res.status().is_success() {
let status = res.status();
let body = res.text().await.unwrap_or_default();
anyhow::bail!("Put record failed: {} - {}", status, body);
}
let result: PutRecordResponse = res.json().await?;
println!("Success!");
println!(" URI: {}", result.uri);
println!(" CID: {}", result.cid);
Ok(())
}
/// Put a lexicon schema
pub async fn put_lexicon(file: &str) -> Result<()> {
let session = auth::refresh_session().await?;
let pds = session.pds.as_deref().unwrap_or("bsky.social");
let content = fs::read_to_string(file)
.with_context(|| format!("Failed to read file: {}", file))?;
let lexicon: Value = serde_json::from_str(&content)?;
let lexicon_id = lexicon["id"]
.as_str()
.context("Lexicon file must have 'id' field")?
.to_string();
let client = reqwest::Client::new();
let url = lexicons::url(pds, &com_atproto_repo::PUT_RECORD);
let req = PutRecordRequest {
repo: session.did.clone(),
collection: "com.atproto.lexicon.schema".to_string(),
rkey: lexicon_id.clone(),
record: lexicon,
};
println!("Putting lexicon: {}", lexicon_id);
println!("{}", serde_json::to_string_pretty(&req)?);
let res = client
.post(&url)
.header("Authorization", format!("Bearer {}", session.access_jwt))
.json(&req)
.send()
.await?;
if !res.status().is_success() {
let status = res.status();
let body = res.text().await.unwrap_or_default();
anyhow::bail!("Put lexicon failed: {} - {}", status, body);
}
let result: PutRecordResponse = res.json().await?;
println!("Success!");
println!(" URI: {}", result.uri);
println!(" CID: {}", result.cid);
Ok(())
}
/// Get records from a collection
pub async fn get_records(collection: &str, limit: u32) -> Result<()> {
let session = auth::refresh_session().await?;
let pds = session.pds.as_deref().unwrap_or("bsky.social");
let client = reqwest::Client::new();
let base_url = lexicons::url(pds, &com_atproto_repo::LIST_RECORDS);
let url = format!(
"{}?repo={}&collection={}&limit={}",
base_url, session.did, collection, limit
);
let res = client
.get(&url)
.header("Authorization", format!("Bearer {}", session.access_jwt))
.send()
.await?;
if !res.status().is_success() {
let status = res.status();
let body = res.text().await.unwrap_or_default();
anyhow::bail!("Get records failed: {} - {}", status, body);
}
let result: ListRecordsResponse = res.json().await?;
println!("Found {} records in {}", result.records.len(), collection);
for record in &result.records {
println!("---");
println!("URI: {}", record.uri);
println!("CID: {}", record.cid);
println!("{}", serde_json::to_string_pretty(&record.value)?);
}
Ok(())
}
/// Delete a record
pub async fn delete_record(collection: &str, rkey: &str) -> Result<()> {
let session = auth::refresh_session().await?;
let pds = session.pds.as_deref().unwrap_or("bsky.social");
let client = reqwest::Client::new();
let url = lexicons::url(pds, &com_atproto_repo::DELETE_RECORD);
let req = DeleteRecordRequest {
repo: session.did.clone(),
collection: collection.to_string(),
rkey: rkey.to_string(),
};
println!("Deleting {} from {}", rkey, collection);
let res = client
.post(&url)
.header("Authorization", format!("Bearer {}", session.access_jwt))
.json(&req)
.send()
.await?;
if !res.status().is_success() {
let status = res.status();
let body = res.text().await.unwrap_or_default();
anyhow::bail!("Delete failed: {} - {}", status, body);
}
println!("Deleted successfully");
Ok(())
}
#[derive(Debug, Deserialize)]
struct Config {
handle: String,
#[serde(default)]
collection: Option<String>,
}
#[derive(Debug, Deserialize)]
struct DescribeRepoResponse {
did: String,
handle: String,
collections: Vec<String>,
}
/// Sync PDS data to local content directory
pub async fn sync_to_local(output: &str, is_bot: bool, collection_override: Option<&str>) -> Result<()> {
let client = reqwest::Client::new();
let (did, pds, _handle, collection) = if is_bot {
// Bot mode: use bot.json
let session = token::load_bot_session()?;
let pds = session.pds.as_deref().unwrap_or("bsky.social");
let collection = collection_override.unwrap_or("ai.syui.log.chat");
println!("Syncing bot data for {} ({})", session.handle, session.did);
(session.did.clone(), format!("https://{}", pds), session.handle.clone(), collection.to_string())
} else {
// User mode: use config.json
let config_content = fs::read_to_string("public/config.json")
.context("config.json not found")?;
let config: Config = serde_json::from_str(&config_content)?;
println!("Syncing data for {}", config.handle);
// Resolve handle to DID
let resolve_url = format!(
"{}?handle={}",
lexicons::url("public.api.bsky.app", &com_atproto_identity::RESOLVE_HANDLE),
config.handle
);
let res = client.get(&resolve_url).send().await?;
let resolve: serde_json::Value = res.json().await?;
let did = resolve["did"].as_str().context("Could not resolve handle")?.to_string();
// Get PDS from DID document
let plc_url = format!("https://plc.directory/{}", did);
let res = client.get(&plc_url).send().await?;
let did_doc: serde_json::Value = res.json().await?;
let pds = did_doc["service"]
.as_array()
.and_then(|services| {
services.iter().find(|s| s["type"] == "AtprotoPersonalDataServer")
})
.and_then(|s| s["serviceEndpoint"].as_str())
.context("Could not find PDS")?
.to_string();
let collection = collection_override
.map(|s| s.to_string())
.unwrap_or_else(|| config.collection.as_deref().unwrap_or("ai.syui.log.post").to_string());
(did, pds, config.handle.clone(), collection)
};
println!("DID: {}", did);
println!("PDS: {}", pds);
// Remove https:// prefix for lexicons::url
let pds_host = pds.trim_start_matches("https://");
// Create output directory
let did_dir = format!("{}/{}", output, did);
fs::create_dir_all(&did_dir)?;
// 1. Sync describeRepo
let describe_url = format!(
"{}?repo={}",
lexicons::url(pds_host, &com_atproto_repo::DESCRIBE_REPO),
did
);
let res = client.get(&describe_url).send().await?;
let describe: DescribeRepoResponse = res.json().await?;
let describe_path = format!("{}/describe.json", did_dir);
let describe_json = serde_json::to_string_pretty(&serde_json::json!({
"did": describe.did,
"handle": describe.handle,
"collections": describe.collections,
}))?;
fs::write(&describe_path, &describe_json)?;
println!("Saved: {}", describe_path);
// 2. Sync profile
let profile_url = format!(
"{}?repo={}&collection=app.bsky.actor.profile&rkey=self",
lexicons::url(pds_host, &com_atproto_repo::GET_RECORD),
did
);
let res = client.get(&profile_url).send().await?;
if res.status().is_success() {
let profile: serde_json::Value = res.json().await?;
let profile_dir = format!("{}/app.bsky.actor.profile", did_dir);
fs::create_dir_all(&profile_dir)?;
let profile_path = format!("{}/self.json", profile_dir);
fs::write(&profile_path, serde_json::to_string_pretty(&profile)?)?;
println!("Saved: {}", profile_path);
// Download avatar blob if present
if let Some(avatar_cid) = profile["value"]["avatar"]["ref"]["$link"].as_str() {
let blob_dir = format!("{}/blob", did_dir);
fs::create_dir_all(&blob_dir)?;
let blob_path = format!("{}/{}", blob_dir, avatar_cid);
let blob_url = format!(
"{}/xrpc/com.atproto.sync.getBlob?did={}&cid={}",
pds, did, avatar_cid
);
println!("Downloading avatar: {}", avatar_cid);
let blob_res = client.get(&blob_url).send().await?;
if blob_res.status().is_success() {
let blob_bytes = blob_res.bytes().await?;
fs::write(&blob_path, &blob_bytes)?;
println!("Saved: {}", blob_path);
} else {
println!("Failed to download avatar: {}", blob_res.status());
}
}
}
// 3. Sync collection records
let records_url = format!(
"{}?repo={}&collection={}&limit=100",
lexicons::url(pds_host, &com_atproto_repo::LIST_RECORDS),
did, collection
);
let res = client.get(&records_url).send().await?;
if res.status().is_success() {
let list: ListRecordsResponse = res.json().await?;
let collection_dir = format!("{}/{}", did_dir, collection);
fs::create_dir_all(&collection_dir)?;
let mut rkeys: Vec<String> = Vec::new();
for record in &list.records {
let rkey = record.uri.split('/').last().unwrap_or("unknown");
rkeys.push(rkey.to_string());
let record_path = format!("{}/{}.json", collection_dir, rkey);
let record_json = serde_json::json!({
"uri": record.uri,
"cid": record.cid,
"value": record.value,
});
fs::write(&record_path, serde_json::to_string_pretty(&record_json)?)?;
println!("Saved: {}", record_path);
}
// Create index.json with list of rkeys
let index_path = format!("{}/index.json", collection_dir);
fs::write(&index_path, serde_json::to_string_pretty(&rkeys)?)?;
println!("Saved: {}", index_path);
println!("Synced {} records from {}", list.records.len(), collection);
}
println!("Sync complete!");
Ok(())
}
/// Push local content to PDS
pub async fn push_to_remote(input: &str, collection: &str, is_bot: bool) -> Result<()> {
let session = if is_bot {
auth::refresh_bot_session().await?
} else {
auth::refresh_session().await?
};
let pds = session.pds.as_deref().unwrap_or("bsky.social");
let did = &session.did;
// Build collection directory path
let collection_dir = format!("{}/{}/{}", input, did, collection);
if !std::path::Path::new(&collection_dir).exists() {
anyhow::bail!("Collection directory not found: {}", collection_dir);
}
println!("Pushing records from {} to {}", collection_dir, collection);
let client = reqwest::Client::new();
let url = lexicons::url(pds, &com_atproto_repo::PUT_RECORD);
let mut count = 0;
for entry in fs::read_dir(&collection_dir)? {
let entry = entry?;
let path = entry.path();
// Skip non-JSON files and index.json
if path.extension().map(|e| e != "json").unwrap_or(true) {
continue;
}
let filename = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
if filename == "index" {
continue;
}
let rkey = filename.to_string();
let content = fs::read_to_string(&path)?;
let record_data: Value = serde_json::from_str(&content)?;
// Extract value from record (sync saves as {uri, cid, value})
let record = if record_data.get("value").is_some() {
record_data["value"].clone()
} else {
record_data
};
let req = PutRecordRequest {
repo: did.clone(),
collection: collection.to_string(),
rkey: rkey.clone(),
record,
};
println!("Pushing: {}", rkey);
let res = client
.post(&url)
.header("Authorization", format!("Bearer {}", session.access_jwt))
.json(&req)
.send()
.await?;
if !res.status().is_success() {
let status = res.status();
let body = res.text().await.unwrap_or_default();
println!(" Failed: {} - {}", status, body);
} else {
let result: PutRecordResponse = res.json().await?;
println!(" OK: {}", result.uri);
count += 1;
}
}
println!("Pushed {} records to {}", count, collection);
Ok(())
}

74
src/commands/token.rs Normal file
View File

@@ -0,0 +1,74 @@
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
/// Bundle ID for the application
pub const BUNDLE_ID: &str = "ai.syui.log";
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Session {
pub did: String,
pub handle: String,
pub access_jwt: String,
pub refresh_jwt: String,
#[serde(default)]
pub pds: Option<String>,
}
/// Get token file path: ~/Library/Application Support/ai.syui.log/token.json
pub fn token_path() -> Result<PathBuf> {
let config_dir = dirs::config_dir()
.context("Could not find config directory")?
.join(BUNDLE_ID);
fs::create_dir_all(&config_dir)?;
Ok(config_dir.join("token.json"))
}
/// Get bot token file path: ~/Library/Application Support/ai.syui.log/bot.json
pub fn bot_token_path() -> Result<PathBuf> {
let config_dir = dirs::config_dir()
.context("Could not find config directory")?
.join(BUNDLE_ID);
fs::create_dir_all(&config_dir)?;
Ok(config_dir.join("bot.json"))
}
/// Load session from token file
pub fn load_session() -> Result<Session> {
let path = token_path()?;
let content = fs::read_to_string(&path)
.with_context(|| format!("Token file not found: {:?}. Run 'ailog login' first.", path))?;
let session: Session = serde_json::from_str(&content)?;
Ok(session)
}
/// Save session to token file
pub fn save_session(session: &Session) -> Result<()> {
let path = token_path()?;
let content = serde_json::to_string_pretty(session)?;
fs::write(&path, content)?;
println!("Token saved to {:?}", path);
Ok(())
}
/// Load bot session from bot token file
pub fn load_bot_session() -> Result<Session> {
let path = bot_token_path()?;
let content = fs::read_to_string(&path)
.with_context(|| format!("Bot token file not found: {:?}. Run 'ailog login --bot' first.", path))?;
let session: Session = serde_json::from_str(&content)?;
Ok(session)
}
/// Save bot session to bot token file
pub fn save_bot_session(session: &Session) -> Result<()> {
let path = bot_token_path()?;
let content = serde_json::to_string_pretty(session)?;
fs::write(&path, content)?;
println!("Bot token saved to {:?}", path);
Ok(())
}

303
src/lexicons/mod.rs Normal file
View File

@@ -0,0 +1,303 @@
//! Auto-generated from ATProto lexicons
//! Run `ailog gen` to regenerate
//! Do not edit manually
#![allow(dead_code)]
#[derive(Debug, Clone, Copy)]
pub struct Endpoint {
pub nsid: &'static str,
pub method: &'static str,
}
/// Build XRPC URL for an endpoint
pub fn url(pds: &str, endpoint: &Endpoint) -> String {
format!("https://{}/xrpc/{}", pds, endpoint.nsid)
}
pub mod app_bsky_actor {
use super::Endpoint;
pub const GET_PREFERENCES: Endpoint = Endpoint { nsid: "app.bsky.actor.getPreferences", method: "GET" };
pub const GET_PROFILE: Endpoint = Endpoint { nsid: "app.bsky.actor.getProfile", method: "GET" };
pub const GET_PROFILES: Endpoint = Endpoint { nsid: "app.bsky.actor.getProfiles", method: "GET" };
pub const GET_SUGGESTIONS: Endpoint = Endpoint { nsid: "app.bsky.actor.getSuggestions", method: "GET" };
pub const PUT_PREFERENCES: Endpoint = Endpoint { nsid: "app.bsky.actor.putPreferences", method: "POST" };
pub const SEARCH_ACTORS: Endpoint = Endpoint { nsid: "app.bsky.actor.searchActors", method: "GET" };
pub const SEARCH_ACTORS_TYPEAHEAD: Endpoint = Endpoint { nsid: "app.bsky.actor.searchActorsTypeahead", method: "GET" };
}
pub mod app_bsky_ageassurance {
use super::Endpoint;
pub const BEGIN: Endpoint = Endpoint { nsid: "app.bsky.ageassurance.begin", method: "POST" };
pub const GET_CONFIG: Endpoint = Endpoint { nsid: "app.bsky.ageassurance.getConfig", method: "GET" };
pub const GET_STATE: Endpoint = Endpoint { nsid: "app.bsky.ageassurance.getState", method: "GET" };
}
pub mod app_bsky_bookmark {
use super::Endpoint;
pub const CREATE_BOOKMARK: Endpoint = Endpoint { nsid: "app.bsky.bookmark.createBookmark", method: "POST" };
pub const DELETE_BOOKMARK: Endpoint = Endpoint { nsid: "app.bsky.bookmark.deleteBookmark", method: "POST" };
pub const GET_BOOKMARKS: Endpoint = Endpoint { nsid: "app.bsky.bookmark.getBookmarks", method: "GET" };
}
pub mod app_bsky_contact {
use super::Endpoint;
pub const DISMISS_MATCH: Endpoint = Endpoint { nsid: "app.bsky.contact.dismissMatch", method: "POST" };
pub const GET_MATCHES: Endpoint = Endpoint { nsid: "app.bsky.contact.getMatches", method: "GET" };
pub const GET_SYNC_STATUS: Endpoint = Endpoint { nsid: "app.bsky.contact.getSyncStatus", method: "GET" };
pub const IMPORT_CONTACTS: Endpoint = Endpoint { nsid: "app.bsky.contact.importContacts", method: "POST" };
pub const REMOVE_DATA: Endpoint = Endpoint { nsid: "app.bsky.contact.removeData", method: "POST" };
pub const SEND_NOTIFICATION: Endpoint = Endpoint { nsid: "app.bsky.contact.sendNotification", method: "POST" };
pub const START_PHONE_VERIFICATION: Endpoint = Endpoint { nsid: "app.bsky.contact.startPhoneVerification", method: "POST" };
pub const VERIFY_PHONE: Endpoint = Endpoint { nsid: "app.bsky.contact.verifyPhone", method: "POST" };
}
pub mod app_bsky_draft {
use super::Endpoint;
pub const CREATE_DRAFT: Endpoint = Endpoint { nsid: "app.bsky.draft.createDraft", method: "POST" };
pub const DELETE_DRAFT: Endpoint = Endpoint { nsid: "app.bsky.draft.deleteDraft", method: "POST" };
pub const GET_DRAFTS: Endpoint = Endpoint { nsid: "app.bsky.draft.getDrafts", method: "GET" };
pub const UPDATE_DRAFT: Endpoint = Endpoint { nsid: "app.bsky.draft.updateDraft", method: "POST" };
}
pub mod app_bsky_feed {
use super::Endpoint;
pub const DESCRIBE_FEED_GENERATOR: Endpoint = Endpoint { nsid: "app.bsky.feed.describeFeedGenerator", method: "GET" };
pub const GET_ACTOR_FEEDS: Endpoint = Endpoint { nsid: "app.bsky.feed.getActorFeeds", method: "GET" };
pub const GET_ACTOR_LIKES: Endpoint = Endpoint { nsid: "app.bsky.feed.getActorLikes", method: "GET" };
pub const GET_AUTHOR_FEED: Endpoint = Endpoint { nsid: "app.bsky.feed.getAuthorFeed", method: "GET" };
pub const GET_FEED: Endpoint = Endpoint { nsid: "app.bsky.feed.getFeed", method: "GET" };
pub const GET_FEED_GENERATOR: Endpoint = Endpoint { nsid: "app.bsky.feed.getFeedGenerator", method: "GET" };
pub const GET_FEED_GENERATORS: Endpoint = Endpoint { nsid: "app.bsky.feed.getFeedGenerators", method: "GET" };
pub const GET_FEED_SKELETON: Endpoint = Endpoint { nsid: "app.bsky.feed.getFeedSkeleton", method: "GET" };
pub const GET_LIKES: Endpoint = Endpoint { nsid: "app.bsky.feed.getLikes", method: "GET" };
pub const GET_LIST_FEED: Endpoint = Endpoint { nsid: "app.bsky.feed.getListFeed", method: "GET" };
pub const GET_POST_THREAD: Endpoint = Endpoint { nsid: "app.bsky.feed.getPostThread", method: "GET" };
pub const GET_POSTS: Endpoint = Endpoint { nsid: "app.bsky.feed.getPosts", method: "GET" };
pub const GET_QUOTES: Endpoint = Endpoint { nsid: "app.bsky.feed.getQuotes", method: "GET" };
pub const GET_REPOSTED_BY: Endpoint = Endpoint { nsid: "app.bsky.feed.getRepostedBy", method: "GET" };
pub const GET_SUGGESTED_FEEDS: Endpoint = Endpoint { nsid: "app.bsky.feed.getSuggestedFeeds", method: "GET" };
pub const GET_TIMELINE: Endpoint = Endpoint { nsid: "app.bsky.feed.getTimeline", method: "GET" };
pub const SEARCH_POSTS: Endpoint = Endpoint { nsid: "app.bsky.feed.searchPosts", method: "GET" };
pub const SEND_INTERACTIONS: Endpoint = Endpoint { nsid: "app.bsky.feed.sendInteractions", method: "POST" };
}
pub mod app_bsky_graph {
use super::Endpoint;
pub const GET_ACTOR_STARTER_PACKS: Endpoint = Endpoint { nsid: "app.bsky.graph.getActorStarterPacks", method: "GET" };
pub const GET_BLOCKS: Endpoint = Endpoint { nsid: "app.bsky.graph.getBlocks", method: "GET" };
pub const GET_FOLLOWERS: Endpoint = Endpoint { nsid: "app.bsky.graph.getFollowers", method: "GET" };
pub const GET_FOLLOWS: Endpoint = Endpoint { nsid: "app.bsky.graph.getFollows", method: "GET" };
pub const GET_KNOWN_FOLLOWERS: Endpoint = Endpoint { nsid: "app.bsky.graph.getKnownFollowers", method: "GET" };
pub const GET_LIST: Endpoint = Endpoint { nsid: "app.bsky.graph.getList", method: "GET" };
pub const GET_LIST_BLOCKS: Endpoint = Endpoint { nsid: "app.bsky.graph.getListBlocks", method: "GET" };
pub const GET_LIST_MUTES: Endpoint = Endpoint { nsid: "app.bsky.graph.getListMutes", method: "GET" };
pub const GET_LISTS: Endpoint = Endpoint { nsid: "app.bsky.graph.getLists", method: "GET" };
pub const GET_LISTS_WITH_MEMBERSHIP: Endpoint = Endpoint { nsid: "app.bsky.graph.getListsWithMembership", method: "GET" };
pub const GET_MUTES: Endpoint = Endpoint { nsid: "app.bsky.graph.getMutes", method: "GET" };
pub const GET_RELATIONSHIPS: Endpoint = Endpoint { nsid: "app.bsky.graph.getRelationships", method: "GET" };
pub const GET_STARTER_PACK: Endpoint = Endpoint { nsid: "app.bsky.graph.getStarterPack", method: "GET" };
pub const GET_STARTER_PACKS: Endpoint = Endpoint { nsid: "app.bsky.graph.getStarterPacks", method: "GET" };
pub const GET_STARTER_PACKS_WITH_MEMBERSHIP: Endpoint = Endpoint { nsid: "app.bsky.graph.getStarterPacksWithMembership", method: "GET" };
pub const GET_SUGGESTED_FOLLOWS_BY_ACTOR: Endpoint = Endpoint { nsid: "app.bsky.graph.getSuggestedFollowsByActor", method: "GET" };
pub const MUTE_ACTOR: Endpoint = Endpoint { nsid: "app.bsky.graph.muteActor", method: "POST" };
pub const MUTE_ACTOR_LIST: Endpoint = Endpoint { nsid: "app.bsky.graph.muteActorList", method: "POST" };
pub const MUTE_THREAD: Endpoint = Endpoint { nsid: "app.bsky.graph.muteThread", method: "POST" };
pub const SEARCH_STARTER_PACKS: Endpoint = Endpoint { nsid: "app.bsky.graph.searchStarterPacks", method: "GET" };
pub const UNMUTE_ACTOR: Endpoint = Endpoint { nsid: "app.bsky.graph.unmuteActor", method: "POST" };
pub const UNMUTE_ACTOR_LIST: Endpoint = Endpoint { nsid: "app.bsky.graph.unmuteActorList", method: "POST" };
pub const UNMUTE_THREAD: Endpoint = Endpoint { nsid: "app.bsky.graph.unmuteThread", method: "POST" };
}
pub mod app_bsky_labeler {
use super::Endpoint;
pub const GET_SERVICES: Endpoint = Endpoint { nsid: "app.bsky.labeler.getServices", method: "GET" };
}
pub mod app_bsky_notification {
use super::Endpoint;
pub const GET_PREFERENCES: Endpoint = Endpoint { nsid: "app.bsky.notification.getPreferences", method: "GET" };
pub const GET_UNREAD_COUNT: Endpoint = Endpoint { nsid: "app.bsky.notification.getUnreadCount", method: "GET" };
pub const LIST_ACTIVITY_SUBSCRIPTIONS: Endpoint = Endpoint { nsid: "app.bsky.notification.listActivitySubscriptions", method: "GET" };
pub const LIST_NOTIFICATIONS: Endpoint = Endpoint { nsid: "app.bsky.notification.listNotifications", method: "GET" };
pub const PUT_ACTIVITY_SUBSCRIPTION: Endpoint = Endpoint { nsid: "app.bsky.notification.putActivitySubscription", method: "POST" };
pub const PUT_PREFERENCES: Endpoint = Endpoint { nsid: "app.bsky.notification.putPreferences", method: "POST" };
pub const PUT_PREFERENCES_V2: Endpoint = Endpoint { nsid: "app.bsky.notification.putPreferencesV2", method: "POST" };
pub const REGISTER_PUSH: Endpoint = Endpoint { nsid: "app.bsky.notification.registerPush", method: "POST" };
pub const UNREGISTER_PUSH: Endpoint = Endpoint { nsid: "app.bsky.notification.unregisterPush", method: "POST" };
pub const UPDATE_SEEN: Endpoint = Endpoint { nsid: "app.bsky.notification.updateSeen", method: "POST" };
}
pub mod app_bsky_unspecced {
use super::Endpoint;
pub const GET_AGE_ASSURANCE_STATE: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getAgeAssuranceState", method: "GET" };
pub const GET_CONFIG: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getConfig", method: "GET" };
pub const GET_ONBOARDING_SUGGESTED_STARTER_PACKS: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getOnboardingSuggestedStarterPacks", method: "GET" };
pub const GET_ONBOARDING_SUGGESTED_STARTER_PACKS_SKELETON: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getOnboardingSuggestedStarterPacksSkeleton", method: "GET" };
pub const GET_POPULAR_FEED_GENERATORS: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getPopularFeedGenerators", method: "GET" };
pub const GET_POST_THREAD_OTHER_V2: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getPostThreadOtherV2", method: "GET" };
pub const GET_POST_THREAD_V2: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getPostThreadV2", method: "GET" };
pub const GET_SUGGESTED_FEEDS: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getSuggestedFeeds", method: "GET" };
pub const GET_SUGGESTED_FEEDS_SKELETON: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getSuggestedFeedsSkeleton", method: "GET" };
pub const GET_SUGGESTED_STARTER_PACKS: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getSuggestedStarterPacks", method: "GET" };
pub const GET_SUGGESTED_STARTER_PACKS_SKELETON: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getSuggestedStarterPacksSkeleton", method: "GET" };
pub const GET_SUGGESTED_USERS: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getSuggestedUsers", method: "GET" };
pub const GET_SUGGESTED_USERS_SKELETON: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getSuggestedUsersSkeleton", method: "GET" };
pub const GET_SUGGESTIONS_SKELETON: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getSuggestionsSkeleton", method: "GET" };
pub const GET_TAGGED_SUGGESTIONS: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getTaggedSuggestions", method: "GET" };
pub const GET_TRENDING_TOPICS: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getTrendingTopics", method: "GET" };
pub const GET_TRENDS: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getTrends", method: "GET" };
pub const GET_TRENDS_SKELETON: Endpoint = Endpoint { nsid: "app.bsky.unspecced.getTrendsSkeleton", method: "GET" };
pub const INIT_AGE_ASSURANCE: Endpoint = Endpoint { nsid: "app.bsky.unspecced.initAgeAssurance", method: "POST" };
pub const SEARCH_ACTORS_SKELETON: Endpoint = Endpoint { nsid: "app.bsky.unspecced.searchActorsSkeleton", method: "GET" };
pub const SEARCH_POSTS_SKELETON: Endpoint = Endpoint { nsid: "app.bsky.unspecced.searchPostsSkeleton", method: "GET" };
pub const SEARCH_STARTER_PACKS_SKELETON: Endpoint = Endpoint { nsid: "app.bsky.unspecced.searchStarterPacksSkeleton", method: "GET" };
}
pub mod app_bsky_video {
use super::Endpoint;
pub const GET_JOB_STATUS: Endpoint = Endpoint { nsid: "app.bsky.video.getJobStatus", method: "GET" };
pub const GET_UPLOAD_LIMITS: Endpoint = Endpoint { nsid: "app.bsky.video.getUploadLimits", method: "GET" };
pub const UPLOAD_VIDEO: Endpoint = Endpoint { nsid: "app.bsky.video.uploadVideo", method: "POST" };
}
pub mod com_atproto_admin {
use super::Endpoint;
pub const DELETE_ACCOUNT: Endpoint = Endpoint { nsid: "com.atproto.admin.deleteAccount", method: "POST" };
pub const DISABLE_ACCOUNT_INVITES: Endpoint = Endpoint { nsid: "com.atproto.admin.disableAccountInvites", method: "POST" };
pub const DISABLE_INVITE_CODES: Endpoint = Endpoint { nsid: "com.atproto.admin.disableInviteCodes", method: "POST" };
pub const ENABLE_ACCOUNT_INVITES: Endpoint = Endpoint { nsid: "com.atproto.admin.enableAccountInvites", method: "POST" };
pub const GET_ACCOUNT_INFO: Endpoint = Endpoint { nsid: "com.atproto.admin.getAccountInfo", method: "GET" };
pub const GET_ACCOUNT_INFOS: Endpoint = Endpoint { nsid: "com.atproto.admin.getAccountInfos", method: "GET" };
pub const GET_INVITE_CODES: Endpoint = Endpoint { nsid: "com.atproto.admin.getInviteCodes", method: "GET" };
pub const GET_SUBJECT_STATUS: Endpoint = Endpoint { nsid: "com.atproto.admin.getSubjectStatus", method: "GET" };
pub const SEARCH_ACCOUNTS: Endpoint = Endpoint { nsid: "com.atproto.admin.searchAccounts", method: "GET" };
pub const SEND_EMAIL: Endpoint = Endpoint { nsid: "com.atproto.admin.sendEmail", method: "POST" };
pub const UPDATE_ACCOUNT_EMAIL: Endpoint = Endpoint { nsid: "com.atproto.admin.updateAccountEmail", method: "POST" };
pub const UPDATE_ACCOUNT_HANDLE: Endpoint = Endpoint { nsid: "com.atproto.admin.updateAccountHandle", method: "POST" };
pub const UPDATE_ACCOUNT_PASSWORD: Endpoint = Endpoint { nsid: "com.atproto.admin.updateAccountPassword", method: "POST" };
pub const UPDATE_ACCOUNT_SIGNING_KEY: Endpoint = Endpoint { nsid: "com.atproto.admin.updateAccountSigningKey", method: "POST" };
pub const UPDATE_SUBJECT_STATUS: Endpoint = Endpoint { nsid: "com.atproto.admin.updateSubjectStatus", method: "POST" };
}
pub mod com_atproto_identity {
use super::Endpoint;
pub const GET_RECOMMENDED_DID_CREDENTIALS: Endpoint = Endpoint { nsid: "com.atproto.identity.getRecommendedDidCredentials", method: "GET" };
pub const REFRESH_IDENTITY: Endpoint = Endpoint { nsid: "com.atproto.identity.refreshIdentity", method: "POST" };
pub const REQUEST_PLC_OPERATION_SIGNATURE: Endpoint = Endpoint { nsid: "com.atproto.identity.requestPlcOperationSignature", method: "POST" };
pub const RESOLVE_DID: Endpoint = Endpoint { nsid: "com.atproto.identity.resolveDid", method: "GET" };
pub const RESOLVE_HANDLE: Endpoint = Endpoint { nsid: "com.atproto.identity.resolveHandle", method: "GET" };
pub const RESOLVE_IDENTITY: Endpoint = Endpoint { nsid: "com.atproto.identity.resolveIdentity", method: "GET" };
pub const SIGN_PLC_OPERATION: Endpoint = Endpoint { nsid: "com.atproto.identity.signPlcOperation", method: "POST" };
pub const SUBMIT_PLC_OPERATION: Endpoint = Endpoint { nsid: "com.atproto.identity.submitPlcOperation", method: "POST" };
pub const UPDATE_HANDLE: Endpoint = Endpoint { nsid: "com.atproto.identity.updateHandle", method: "POST" };
}
pub mod com_atproto_label {
use super::Endpoint;
pub const QUERY_LABELS: Endpoint = Endpoint { nsid: "com.atproto.label.queryLabels", method: "GET" };
}
pub mod com_atproto_lexicon {
use super::Endpoint;
pub const RESOLVE_LEXICON: Endpoint = Endpoint { nsid: "com.atproto.lexicon.resolveLexicon", method: "GET" };
}
pub mod com_atproto_moderation {
use super::Endpoint;
pub const CREATE_REPORT: Endpoint = Endpoint { nsid: "com.atproto.moderation.createReport", method: "POST" };
}
pub mod com_atproto_repo {
use super::Endpoint;
pub const APPLY_WRITES: Endpoint = Endpoint { nsid: "com.atproto.repo.applyWrites", method: "POST" };
pub const CREATE_RECORD: Endpoint = Endpoint { nsid: "com.atproto.repo.createRecord", method: "POST" };
pub const DELETE_RECORD: Endpoint = Endpoint { nsid: "com.atproto.repo.deleteRecord", method: "POST" };
pub const DESCRIBE_REPO: Endpoint = Endpoint { nsid: "com.atproto.repo.describeRepo", method: "GET" };
pub const GET_RECORD: Endpoint = Endpoint { nsid: "com.atproto.repo.getRecord", method: "GET" };
pub const IMPORT_REPO: Endpoint = Endpoint { nsid: "com.atproto.repo.importRepo", method: "POST" };
pub const LIST_MISSING_BLOBS: Endpoint = Endpoint { nsid: "com.atproto.repo.listMissingBlobs", method: "GET" };
pub const LIST_RECORDS: Endpoint = Endpoint { nsid: "com.atproto.repo.listRecords", method: "GET" };
pub const PUT_RECORD: Endpoint = Endpoint { nsid: "com.atproto.repo.putRecord", method: "POST" };
pub const UPLOAD_BLOB: Endpoint = Endpoint { nsid: "com.atproto.repo.uploadBlob", method: "POST" };
}
pub mod com_atproto_server {
use super::Endpoint;
pub const ACTIVATE_ACCOUNT: Endpoint = Endpoint { nsid: "com.atproto.server.activateAccount", method: "POST" };
pub const CHECK_ACCOUNT_STATUS: Endpoint = Endpoint { nsid: "com.atproto.server.checkAccountStatus", method: "GET" };
pub const CONFIRM_EMAIL: Endpoint = Endpoint { nsid: "com.atproto.server.confirmEmail", method: "POST" };
pub const CREATE_ACCOUNT: Endpoint = Endpoint { nsid: "com.atproto.server.createAccount", method: "POST" };
pub const CREATE_APP_PASSWORD: Endpoint = Endpoint { nsid: "com.atproto.server.createAppPassword", method: "POST" };
pub const CREATE_INVITE_CODE: Endpoint = Endpoint { nsid: "com.atproto.server.createInviteCode", method: "POST" };
pub const CREATE_INVITE_CODES: Endpoint = Endpoint { nsid: "com.atproto.server.createInviteCodes", method: "POST" };
pub const CREATE_SESSION: Endpoint = Endpoint { nsid: "com.atproto.server.createSession", method: "POST" };
pub const DEACTIVATE_ACCOUNT: Endpoint = Endpoint { nsid: "com.atproto.server.deactivateAccount", method: "POST" };
pub const DELETE_ACCOUNT: Endpoint = Endpoint { nsid: "com.atproto.server.deleteAccount", method: "POST" };
pub const DELETE_SESSION: Endpoint = Endpoint { nsid: "com.atproto.server.deleteSession", method: "POST" };
pub const DESCRIBE_SERVER: Endpoint = Endpoint { nsid: "com.atproto.server.describeServer", method: "GET" };
pub const GET_ACCOUNT_INVITE_CODES: Endpoint = Endpoint { nsid: "com.atproto.server.getAccountInviteCodes", method: "GET" };
pub const GET_SERVICE_AUTH: Endpoint = Endpoint { nsid: "com.atproto.server.getServiceAuth", method: "GET" };
pub const GET_SESSION: Endpoint = Endpoint { nsid: "com.atproto.server.getSession", method: "GET" };
pub const LIST_APP_PASSWORDS: Endpoint = Endpoint { nsid: "com.atproto.server.listAppPasswords", method: "GET" };
pub const REFRESH_SESSION: Endpoint = Endpoint { nsid: "com.atproto.server.refreshSession", method: "POST" };
pub const REQUEST_ACCOUNT_DELETE: Endpoint = Endpoint { nsid: "com.atproto.server.requestAccountDelete", method: "POST" };
pub const REQUEST_EMAIL_CONFIRMATION: Endpoint = Endpoint { nsid: "com.atproto.server.requestEmailConfirmation", method: "POST" };
pub const REQUEST_EMAIL_UPDATE: Endpoint = Endpoint { nsid: "com.atproto.server.requestEmailUpdate", method: "POST" };
pub const REQUEST_PASSWORD_RESET: Endpoint = Endpoint { nsid: "com.atproto.server.requestPasswordReset", method: "POST" };
pub const RESERVE_SIGNING_KEY: Endpoint = Endpoint { nsid: "com.atproto.server.reserveSigningKey", method: "POST" };
pub const RESET_PASSWORD: Endpoint = Endpoint { nsid: "com.atproto.server.resetPassword", method: "POST" };
pub const REVOKE_APP_PASSWORD: Endpoint = Endpoint { nsid: "com.atproto.server.revokeAppPassword", method: "POST" };
pub const UPDATE_EMAIL: Endpoint = Endpoint { nsid: "com.atproto.server.updateEmail", method: "POST" };
}
pub mod com_atproto_sync {
use super::Endpoint;
pub const GET_BLOB: Endpoint = Endpoint { nsid: "com.atproto.sync.getBlob", method: "GET" };
pub const GET_BLOCKS: Endpoint = Endpoint { nsid: "com.atproto.sync.getBlocks", method: "GET" };
pub const GET_CHECKOUT: Endpoint = Endpoint { nsid: "com.atproto.sync.getCheckout", method: "GET" };
pub const GET_HEAD: Endpoint = Endpoint { nsid: "com.atproto.sync.getHead", method: "GET" };
pub const GET_HOST_STATUS: Endpoint = Endpoint { nsid: "com.atproto.sync.getHostStatus", method: "GET" };
pub const GET_LATEST_COMMIT: Endpoint = Endpoint { nsid: "com.atproto.sync.getLatestCommit", method: "GET" };
pub const GET_RECORD: Endpoint = Endpoint { nsid: "com.atproto.sync.getRecord", method: "GET" };
pub const GET_REPO: Endpoint = Endpoint { nsid: "com.atproto.sync.getRepo", method: "GET" };
pub const GET_REPO_STATUS: Endpoint = Endpoint { nsid: "com.atproto.sync.getRepoStatus", method: "GET" };
pub const LIST_BLOBS: Endpoint = Endpoint { nsid: "com.atproto.sync.listBlobs", method: "GET" };
pub const LIST_HOSTS: Endpoint = Endpoint { nsid: "com.atproto.sync.listHosts", method: "GET" };
pub const LIST_REPOS: Endpoint = Endpoint { nsid: "com.atproto.sync.listRepos", method: "GET" };
pub const LIST_REPOS_BY_COLLECTION: Endpoint = Endpoint { nsid: "com.atproto.sync.listReposByCollection", method: "GET" };
pub const NOTIFY_OF_UPDATE: Endpoint = Endpoint { nsid: "com.atproto.sync.notifyOfUpdate", method: "POST" };
pub const REQUEST_CRAWL: Endpoint = Endpoint { nsid: "com.atproto.sync.requestCrawl", method: "POST" };
}
pub mod com_atproto_temp {
use super::Endpoint;
pub const ADD_RESERVED_HANDLE: Endpoint = Endpoint { nsid: "com.atproto.temp.addReservedHandle", method: "POST" };
pub const CHECK_HANDLE_AVAILABILITY: Endpoint = Endpoint { nsid: "com.atproto.temp.checkHandleAvailability", method: "GET" };
pub const CHECK_SIGNUP_QUEUE: Endpoint = Endpoint { nsid: "com.atproto.temp.checkSignupQueue", method: "GET" };
pub const DEREFERENCE_SCOPE: Endpoint = Endpoint { nsid: "com.atproto.temp.dereferenceScope", method: "GET" };
pub const FETCH_LABELS: Endpoint = Endpoint { nsid: "com.atproto.temp.fetchLabels", method: "GET" };
pub const REQUEST_PHONE_VERIFICATION: Endpoint = Endpoint { nsid: "com.atproto.temp.requestPhoneVerification", method: "POST" };
pub const REVOKE_ACCOUNT_CREDENTIALS: Endpoint = Endpoint { nsid: "com.atproto.temp.revokeAccountCredentials", method: "POST" };
}

395
src/lms/chat.rs Normal file
View File

@@ -0,0 +1,395 @@
use anyhow::{anyhow, Result};
use rustyline::error::ReadlineError;
use rustyline::DefaultEditor;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::env;
use std::fs;
use std::path::Path;
use crate::commands::token::{self, BUNDLE_ID};
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ChatMessage {
role: String,
content: String,
}
#[derive(Debug, Serialize)]
struct ChatRequest {
model: String,
messages: Vec<ChatMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
max_tokens: Option<u32>,
}
#[derive(Debug, Deserialize)]
struct ChatChoice {
message: ChatMessageResponse,
}
#[derive(Debug, Deserialize)]
struct ChatMessageResponse {
content: String,
}
#[derive(Debug, Deserialize)]
struct ChatResponse {
choices: Vec<ChatChoice>,
}
#[derive(Debug, Serialize, Deserialize)]
struct ChatRecord {
uri: String,
cid: String,
value: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ChatSession {
root_uri: Option<String>,
last_uri: Option<String>,
messages: Vec<ChatMessage>,
}
impl Default for ChatSession {
fn default() -> Self {
Self {
root_uri: None,
last_uri: None,
messages: Vec::new(),
}
}
}
/// Get system prompt from environment or file
fn get_system_prompt() -> String {
// 1. Try CHAT_SYSTEM env var directly
if let Ok(prompt) = env::var("CHAT_SYSTEM") {
return prompt;
}
// 2. Try CHAT_SYSTEM_FILE env var (path to file)
if let Ok(file_path) = env::var("CHAT_SYSTEM_FILE") {
if let Ok(content) = fs::read_to_string(&file_path) {
return content.trim().to_string();
}
}
// 3. Default prompt
"You are a helpful assistant. Respond concisely.".to_string()
}
/// Create new chat session with system prompt
fn new_session_with_prompt() -> ChatSession {
ChatSession {
root_uri: None,
last_uri: None,
messages: vec![ChatMessage {
role: "system".to_string(),
content: get_system_prompt(),
}],
}
}
/// Get session file path
fn session_path() -> Result<std::path::PathBuf> {
let config_dir = dirs::config_dir()
.ok_or_else(|| anyhow!("Could not find config directory"))?
.join(BUNDLE_ID);
fs::create_dir_all(&config_dir)?;
Ok(config_dir.join("chat_session.json"))
}
/// Load chat session (updates system prompt from current env)
fn load_session() -> Result<ChatSession> {
let path = session_path()?;
if path.exists() {
let content = fs::read_to_string(&path)?;
let mut session: ChatSession = serde_json::from_str(&content)?;
// Update system prompt from current environment
let system_prompt = get_system_prompt();
if let Some(first) = session.messages.first_mut() {
if first.role == "system" {
first.content = system_prompt;
}
} else {
session.messages.insert(0, ChatMessage {
role: "system".to_string(),
content: system_prompt,
});
}
Ok(session)
} else {
Ok(ChatSession::default())
}
}
/// Save chat session
fn save_session(session: &ChatSession) -> Result<()> {
let path = session_path()?;
let content = serde_json::to_string_pretty(session)?;
fs::write(&path, content)?;
Ok(())
}
/// Generate TID
fn generate_tid() -> String {
use rand::Rng;
const CHARSET: &[u8] = b"234567abcdefghijklmnopqrstuvwxyz";
let mut rng = rand::thread_rng();
(0..13)
.map(|_| {
let idx = rng.gen_range(0..CHARSET.len());
CHARSET[idx] as char
})
.collect()
}
/// Call LLM API
async fn call_llm(client: &reqwest::Client, url: &str, model: &str, messages: &[ChatMessage]) -> Result<String> {
let max_tokens = env::var("CHAT_MAX_TOKENS")
.ok()
.and_then(|v| v.parse().ok());
let req = ChatRequest {
model: model.to_string(),
messages: messages.to_vec(),
max_tokens,
};
let res = client.post(url).json(&req).send().await?;
if !res.status().is_success() {
let status = res.status();
let body = res.text().await?;
return Err(anyhow!("LLM call failed ({}): {}", status, body));
}
let chat_res: ChatResponse = res.json().await?;
chat_res
.choices
.first()
.map(|c| c.message.content.trim().to_string())
.ok_or_else(|| anyhow!("No response from LLM"))
}
/// Save chat record to local file
fn save_chat_local(
output_dir: &str,
did: &str,
content: &str,
author_did: &str,
root_uri: Option<&str>,
parent_uri: Option<&str>,
) -> Result<String> {
let rkey = generate_tid();
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
let uri = format!("at://{}/ai.syui.log.chat/{}", did, rkey);
let mut value = serde_json::json!({
"$type": "ai.syui.log.chat",
"content": content,
"author": author_did,
"createdAt": now,
});
if let Some(root) = root_uri {
value["root"] = serde_json::json!(root);
}
if let Some(parent) = parent_uri {
value["parent"] = serde_json::json!(parent);
}
let record = ChatRecord {
uri: uri.clone(),
cid: format!("bafyrei{}", rkey),
value,
};
// Create directory: {output_dir}/{did}/ai.syui.log.chat/
let collection_dir = Path::new(output_dir)
.join(did)
.join("ai.syui.log.chat");
fs::create_dir_all(&collection_dir)?;
// Save record: {rkey}.json
let file_path = collection_dir.join(format!("{}.json", rkey));
let json_content = serde_json::to_string_pretty(&record)?;
fs::write(&file_path, json_content)?;
// Update index.json
let index_path = collection_dir.join("index.json");
let mut rkeys: Vec<String> = if index_path.exists() {
let index_content = fs::read_to_string(&index_path).unwrap_or_else(|_| "[]".to_string());
serde_json::from_str(&index_content).unwrap_or_else(|_| Vec::new())
} else {
Vec::new()
};
if !rkeys.contains(&rkey.to_string()) {
rkeys.push(rkey.to_string());
fs::write(&index_path, serde_json::to_string_pretty(&rkeys)?)?;
}
Ok(uri)
}
/// Process a single message and get response
async fn process_message(
client: &reqwest::Client,
llm_url: &str,
model: &str,
output_dir: &str,
user_did: &str,
bot_did: &str,
session: &mut ChatSession,
input: &str,
) -> Result<String> {
// Add user message to history
session.messages.push(ChatMessage {
role: "user".to_string(),
content: input.to_string(),
});
// Save user message to local file
let user_uri = save_chat_local(
output_dir,
user_did,
input,
user_did,
session.root_uri.as_deref(),
session.last_uri.as_deref(),
)?;
// Set root if first message
if session.root_uri.is_none() {
session.root_uri = Some(user_uri.clone());
}
// Call LLM with full history
let response = call_llm(client, llm_url, model, &session.messages).await?;
// Add assistant message to history
session.messages.push(ChatMessage {
role: "assistant".to_string(),
content: response.clone(),
});
// Save AI response to local file
let ai_uri = save_chat_local(
output_dir,
bot_did,
&response,
bot_did,
session.root_uri.as_deref(),
Some(&user_uri),
)?;
session.last_uri = Some(ai_uri);
// Save session
save_session(session)?;
Ok(response)
}
/// Run chat - interactive or single message
pub async fn run(input: Option<&str>, new_session: bool) -> Result<()> {
let chat_url = env::var("CHAT_URL")
.or_else(|_| env::var("TRANSLATE_URL"))
.unwrap_or_else(|_| "http://127.0.0.1:1234/v1".to_string());
let model = env::var("CHAT_MODEL")
.or_else(|_| env::var("TRANSLATE_MODEL"))
.unwrap_or_else(|_| "gpt-oss".to_string());
let output_dir = env::var("CHAT_OUTPUT").unwrap_or_else(|_| {
// Use absolute path from current working directory
let cwd = env::current_dir().unwrap_or_default();
cwd.join("public/content").to_string_lossy().to_string()
});
// Load user session for DID
let user_token = token::load_session()?;
let user_did = user_token.did.clone();
// Load bot session for DID (required)
let bot_did = match token::load_bot_session() {
Ok(s) => s.did,
Err(_) => {
eprintln!("Bot session not found. Please login as bot first:");
eprintln!(" ailog login <handle> -p <password> -s <server> --bot");
return Ok(());
}
};
// Load or create chat session
let mut session = if new_session {
new_session_with_prompt()
} else {
load_session().unwrap_or_else(|_| new_session_with_prompt())
};
let client = reqwest::Client::new();
let llm_url = format!("{}/chat/completions", chat_url);
// Single message mode
if let Some(msg) = input {
let response = process_message(
&client, &llm_url, &model, &output_dir,
&user_did, &bot_did, &mut session, msg,
).await?;
println!("{}", response);
use std::io::Write;
std::io::stdout().flush()?;
return Ok(());
}
// Interactive mode
println!("ailog chat (type 'exit' to quit, Ctrl+C to cancel)");
println!("model: {}", model);
println!("---");
let mut rl = DefaultEditor::new()?;
loop {
match rl.readline("> ") {
Ok(line) => {
let input = line.trim();
if input.is_empty() {
continue;
}
if input == "exit" || input == "quit" {
break;
}
let _ = rl.add_history_entry(input);
match process_message(
&client, &llm_url, &model, &output_dir,
&user_did, &bot_did, &mut session, input,
).await {
Ok(response) => println!("\n{}\n", response),
Err(e) => {
eprintln!("Error: {}", e);
// Remove failed message from history
session.messages.pop();
}
}
}
Err(ReadlineError::Interrupted) => {
println!("^C");
continue;
}
Err(ReadlineError::Eof) => {
break;
}
Err(e) => {
eprintln!("Error: {}", e);
break;
}
}
}
Ok(())
}

2
src/lms/mod.rs Normal file
View File

@@ -0,0 +1,2 @@
pub mod chat;
pub mod translate;

244
src/lms/translate.rs Normal file
View File

@@ -0,0 +1,244 @@
use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize};
use std::env;
use std::fs;
use std::path::Path;
#[derive(Debug, Serialize)]
struct ChatMessage {
role: String,
content: String,
}
#[derive(Debug, Serialize)]
struct ChatRequest {
model: String,
messages: Vec<ChatMessage>,
}
#[derive(Debug, Deserialize)]
struct ChatChoice {
message: ChatMessageResponse,
}
#[derive(Debug, Deserialize)]
struct ChatMessageResponse {
content: String,
}
#[derive(Debug, Deserialize)]
struct ChatResponse {
choices: Vec<ChatChoice>,
}
/// Translate a file or folder
pub async fn run(input: &Path, from: &str, to: &str) -> Result<()> {
if input.is_dir() {
translate_folder(input, from, to).await
} else {
translate_file(input, from, to).await
}
}
async fn translate_text(
client: &reqwest::Client,
url: &str,
model: &str,
text: &str,
from: &str,
to: &str,
) -> Result<String> {
let from_lang = lang_name(from);
let to_lang = lang_name(to);
let system_content = "<|plamo:op|>dataset\ntranslation".to_string();
let user_content = format!(
"<|plamo:op|>input lang={}\n{}\n<|plamo:op|>output lang={}",
from_lang, text, to_lang
);
let req = ChatRequest {
model: model.to_string(),
messages: vec![
ChatMessage {
role: "system".to_string(),
content: system_content,
},
ChatMessage {
role: "user".to_string(),
content: user_content,
},
],
};
let res = client.post(url).json(&req).send().await?;
if !res.status().is_success() {
let status = res.status();
let body = res.text().await?;
return Err(anyhow!("Translation failed ({}): {}", status, body));
}
let chat_res: ChatResponse = res.json().await?;
chat_res
.choices
.first()
.map(|c| c.message.content.trim().to_string())
.ok_or_else(|| anyhow!("No translation result"))
}
async fn translate_file(input: &Path, from: &str, to: &str) -> Result<()> {
let translate_url =
env::var("TRANSLATE_URL").unwrap_or_else(|_| "http://127.0.0.1:1234/v1".to_string());
let model =
env::var("TRANSLATE_MODEL").unwrap_or_else(|_| "plamo-2-translate".to_string());
println!("Translating: {}", input.display());
// Read input JSON
let content = fs::read_to_string(input)?;
let mut record: serde_json::Value = serde_json::from_str(&content)?;
// Handle both direct format and wrapped format (with "value" field)
let value = if record.get("value").is_some() {
record.get_mut("value").unwrap()
} else {
&mut record
};
// Check if already translated
if value
.get("translations")
.and_then(|t| t.get(to))
.is_some()
{
println!(" Skipped (already has {} translation)", to);
return Ok(());
}
let client = reqwest::Client::new();
let url = format!("{}/chat/completions", translate_url);
// Translate title if exists
let translated_title = if let Some(title) = value.get("title").and_then(|v| v.as_str()) {
if !title.is_empty() {
Some(translate_text(&client, &url, &model, title, from, to).await?)
} else {
None
}
} else {
None
};
// Get and translate content
let text = value
.get("content")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow!("No 'content' field in JSON"))?;
let translated_content = translate_text(&client, &url, &model, text, from, to).await?;
// Add translation to value
let translations = value
.as_object_mut()
.ok_or_else(|| anyhow!("Invalid JSON"))?
.entry("translations")
.or_insert_with(|| serde_json::json!({}));
let mut translation_entry = serde_json::json!({
"content": translated_content
});
if let Some(title) = translated_title {
translation_entry
.as_object_mut()
.unwrap()
.insert("title".to_string(), serde_json::json!(title));
}
translations
.as_object_mut()
.ok_or_else(|| anyhow!("Invalid translations field"))?
.insert(to.to_string(), translation_entry);
// Write back
let output = serde_json::to_string_pretty(&record)?;
fs::write(input, output)?;
println!(" OK");
Ok(())
}
fn collect_json_files(dir: &Path, files: &mut Vec<std::path::PathBuf>) -> Result<()> {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
collect_json_files(&path, files)?;
} else if path.extension().map(|e| e == "json").unwrap_or(false) {
// Skip non-post files (describe.json, self.json, index.json)
let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if filename != "describe.json" && filename != "self.json" && filename != "index.json" {
files.push(path);
}
}
}
Ok(())
}
async fn translate_folder(dir: &Path, from: &str, to: &str) -> Result<()> {
let mut files = Vec::new();
collect_json_files(dir, &mut files)?;
files.sort();
println!("Translating {} files ({} -> {})", files.len(), from, to);
let mut success = 0;
let mut skipped = 0;
let mut failed = 0;
for path in &files {
match translate_file(path, from, to).await {
Ok(_) => {
// Check if it was actually translated or skipped
let content = fs::read_to_string(&path)?;
let record: serde_json::Value = serde_json::from_str(&content)?;
let value = record.get("value").unwrap_or(&record);
if value
.get("translations")
.and_then(|t| t.get(to))
.is_some()
{
success += 1;
} else {
skipped += 1;
}
}
Err(e) => {
eprintln!(" ERROR {}: {}", path.display(), e);
failed += 1;
}
}
}
println!(
"\nDone: {} translated, {} skipped, {} failed",
success, skipped, failed
);
Ok(())
}
fn lang_name(code: &str) -> &str {
match code {
"ja" => "Japanese",
"en" => "English",
"zh" => "Chinese",
"ko" => "Korean",
"fr" => "French",
"de" => "German",
"es" => "Spanish",
_ => code,
}
}

196
src/main.rs Normal file
View File

@@ -0,0 +1,196 @@
mod commands;
mod lexicons;
mod lms;
mod mcp;
use anyhow::Result;
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "ailog")]
#[command(about = "ATProto blog CLI")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Login to ATProto PDS
#[command(alias = "l")]
Login {
/// Handle (e.g., user.bsky.social)
handle: String,
/// Password
#[arg(short, long)]
password: String,
/// PDS server
#[arg(short, long, default_value = "bsky.social")]
server: String,
/// Login as bot (saves to bot.json)
#[arg(long)]
bot: bool,
},
/// Update lexicon schema
Lexicon {
/// Lexicon JSON file
file: String,
},
/// Post a record
#[command(alias = "p")]
Post {
/// Record JSON file
file: String,
/// Collection (e.g., ai.syui.log.post)
#[arg(short, long)]
collection: String,
/// Record key (auto-generated if not provided)
#[arg(short, long)]
rkey: Option<String>,
},
/// Get records from collection
#[command(alias = "g")]
Get {
/// Collection (e.g., ai.syui.log.post)
#[arg(short, long)]
collection: String,
/// Limit
#[arg(short, long, default_value = "10")]
limit: u32,
},
/// Delete a record
#[command(alias = "d")]
Delete {
/// Collection (e.g., ai.syui.log.post)
#[arg(short, long)]
collection: String,
/// Record key
#[arg(short, long)]
rkey: String,
},
/// Sync PDS data to local content directory
#[command(alias = "s")]
Sync {
/// Output directory
#[arg(short, long, default_value = "public/content")]
output: String,
/// Sync bot data (uses bot.json)
#[arg(long)]
bot: bool,
/// Collection to sync (for bot)
#[arg(short, long)]
collection: Option<String>,
},
/// Push local content to PDS
Push {
/// Input directory
#[arg(short, long, default_value = "public/content")]
input: String,
/// Collection (e.g., ai.syui.log.post)
#[arg(short, long, default_value = "ai.syui.log.post")]
collection: String,
/// Push as bot (uses bot.json)
#[arg(long)]
bot: bool,
},
/// Generate lexicon Rust code from ATProto lexicon JSON files
Gen {
/// Input directory containing lexicon JSON files
#[arg(short, long, default_value = "./repos/atproto/lexicons")]
input: String,
/// Output directory for generated Rust code
#[arg(short, long, default_value = "./src/lexicons")]
output: String,
},
/// Translate content files
Lang {
/// Input file or directory
input: String,
/// Source language
#[arg(short, long, default_value = "ja")]
from: String,
/// Target language
#[arg(short, long, default_value = "en")]
to: String,
},
/// Resolve handle to DID
Did {
/// Handle (e.g., syui.ai)
handle: String,
/// Server
#[arg(short, long, default_value = "bsky.social")]
server: String,
},
/// Chat with AI
#[command(alias = "c")]
Chat {
/// Message to send (optional, starts interactive mode if omitted)
message: Option<String>,
/// Start new conversation
#[arg(long)]
new: bool,
},
/// Run MCP server (for Claude Code integration)
#[command(name = "mcp-serve")]
McpServe,
}
#[tokio::main]
async fn main() -> Result<()> {
// Load .env file if exists
dotenvy::dotenv().ok();
let cli = Cli::parse();
match cli.command {
Commands::Login { handle, password, server, bot } => {
commands::auth::login(&handle, &password, &server, bot).await?;
}
Commands::Lexicon { file } => {
commands::post::put_lexicon(&file).await?;
}
Commands::Post { file, collection, rkey } => {
commands::post::put_record(&file, &collection, rkey.as_deref()).await?;
}
Commands::Get { collection, limit } => {
commands::post::get_records(&collection, limit).await?;
}
Commands::Delete { collection, rkey } => {
commands::post::delete_record(&collection, &rkey).await?;
}
Commands::Sync { output, bot, collection } => {
commands::post::sync_to_local(&output, bot, collection.as_deref()).await?;
}
Commands::Push { input, collection, bot } => {
commands::post::push_to_remote(&input, &collection, bot).await?;
}
Commands::Gen { input, output } => {
commands::gen::generate(&input, &output)?;
}
Commands::Lang { input, from, to } => {
commands::lang::translate(&input, &from, &to).await?;
}
Commands::Did { handle, server } => {
commands::did::resolve(&handle, &server).await?;
}
Commands::Chat { message, new } => {
lms::chat::run(message.as_deref(), new).await?;
}
Commands::McpServe => {
mcp::serve()?;
}
}
Ok(())
}

515
src/mcp/mod.rs Normal file
View File

@@ -0,0 +1,515 @@
use anyhow::Result;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::io::{self, BufRead, Write};
use std::fs;
use std::env;
use crate::commands::token;
const BUNDLE_ID: &str = "ai.syui.log";
// JSON-RPC types
#[derive(Debug, Deserialize)]
struct JsonRpcRequest {
jsonrpc: String,
id: Option<Value>,
method: String,
#[serde(default)]
params: Value,
}
#[derive(Debug, Serialize)]
struct JsonRpcResponse {
jsonrpc: String,
id: Value,
#[serde(skip_serializing_if = "Option::is_none")]
result: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<JsonRpcError>,
}
#[derive(Debug, Serialize)]
struct JsonRpcError {
code: i32,
message: String,
}
// MCP types
#[derive(Debug, Serialize)]
struct ServerInfo {
name: String,
version: String,
}
#[derive(Debug, Serialize)]
struct InitializeResult {
#[serde(rename = "protocolVersion")]
protocol_version: String,
capabilities: Capabilities,
#[serde(rename = "serverInfo")]
server_info: ServerInfo,
}
#[derive(Debug, Serialize)]
struct Capabilities {
tools: ToolsCapability,
}
#[derive(Debug, Serialize)]
struct ToolsCapability {
#[serde(rename = "listChanged")]
list_changed: bool,
}
#[derive(Debug, Serialize)]
struct Tool {
name: String,
description: String,
#[serde(rename = "inputSchema")]
input_schema: Value,
}
#[derive(Debug, Serialize)]
struct ToolsListResult {
tools: Vec<Tool>,
}
#[derive(Debug, Serialize)]
struct ToolResult {
content: Vec<ToolContent>,
#[serde(rename = "isError", skip_serializing_if = "Option::is_none")]
is_error: Option<bool>,
}
#[derive(Debug, Serialize)]
struct ToolContent {
#[serde(rename = "type")]
content_type: String,
text: String,
}
// Chat save parameters
#[derive(Debug, Deserialize)]
struct ChatSaveParams {
user_message: String,
bot_response: String,
#[serde(default)]
new_thread: bool,
}
// Chat record structure
#[derive(Debug, Serialize)]
struct ChatRecord {
uri: String,
cid: String,
value: Value,
}
// Session for thread tracking
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct McpSession {
root_uri: Option<String>,
last_uri: Option<String>,
}
fn session_path() -> Result<std::path::PathBuf> {
let config_dir = dirs::config_dir()
.ok_or_else(|| anyhow::anyhow!("Could not find config directory"))?
.join(BUNDLE_ID);
fs::create_dir_all(&config_dir)?;
Ok(config_dir.join("mcp_session.json"))
}
fn load_mcp_session() -> McpSession {
session_path()
.ok()
.and_then(|p| fs::read_to_string(p).ok())
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
fn save_mcp_session(session: &McpSession) -> Result<()> {
let path = session_path()?;
fs::write(path, serde_json::to_string_pretty(session)?)?;
Ok(())
}
/// Generate TID (timestamp-based ID)
fn generate_tid() -> String {
const CHARSET: &[u8] = b"234567abcdefghijklmnopqrstuvwxyz";
use rand::Rng;
let mut rng = rand::thread_rng();
(0..13)
.map(|_| {
let idx = rng.gen_range(0..CHARSET.len());
CHARSET[idx] as char
})
.collect()
}
/// Save chat record to local file
fn save_chat_record(
output_dir: &str,
did: &str,
content: &str,
author_did: &str,
root_uri: Option<&str>,
parent_uri: Option<&str>,
) -> Result<String> {
let rkey = generate_tid();
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string();
let uri = format!("at://{}/ai.syui.log.chat/{}", did, rkey);
let mut value = json!({
"$type": "ai.syui.log.chat",
"content": content,
"author": author_did,
"createdAt": now,
});
if let Some(root) = root_uri {
value["root"] = json!(root);
}
if let Some(parent) = parent_uri {
value["parent"] = json!(parent);
}
let record = ChatRecord {
uri: uri.clone(),
cid: format!("bafyrei{}", rkey),
value,
};
// Create directory
let collection_dir = std::path::Path::new(output_dir)
.join(did)
.join("ai.syui.log.chat");
fs::create_dir_all(&collection_dir)?;
// Save record
let file_path = collection_dir.join(format!("{}.json", rkey));
fs::write(&file_path, serde_json::to_string_pretty(&record)?)?;
// Update index.json
let index_path = collection_dir.join("index.json");
let mut rkeys: Vec<String> = if index_path.exists() {
let content = fs::read_to_string(&index_path).unwrap_or_else(|_| "[]".to_string());
serde_json::from_str(&content).unwrap_or_default()
} else {
Vec::new()
};
if !rkeys.contains(&rkey) {
rkeys.push(rkey);
fs::write(&index_path, serde_json::to_string_pretty(&rkeys)?)?;
}
Ok(uri)
}
/// Handle chat_save tool
fn handle_chat_save(params: ChatSaveParams) -> Result<String> {
// Load session
let mut session = load_mcp_session();
// Get output directory
let output_dir = env::var("CHAT_OUTPUT").unwrap_or_else(|_| {
env::current_dir()
.unwrap_or_default()
.join("public/content")
.to_string_lossy()
.to_string()
});
// Get user DID from token.json
let user_did = token::load_session()
.map(|s| s.did)
.unwrap_or_else(|_| "did:plc:unknown".to_string());
// Get bot DID from bot.json
let bot_did = token::load_bot_session()
.map(|s| s.did)
.unwrap_or_else(|_| "did:plc:6qyecktefllvenje24fcxnie".to_string());
// Reset session if new_thread requested
if params.new_thread {
session = McpSession::default();
}
// Save user message
let user_uri = save_chat_record(
&output_dir,
&user_did,
&params.user_message,
&user_did,
session.root_uri.as_deref(),
session.last_uri.as_deref(),
)?;
// Set root if new thread
if session.root_uri.is_none() {
session.root_uri = Some(user_uri.clone());
}
// Save bot response
let bot_uri = save_chat_record(
&output_dir,
&bot_did,
&params.bot_response,
&bot_did,
session.root_uri.as_deref(),
Some(&user_uri),
)?;
session.last_uri = Some(bot_uri.clone());
save_mcp_session(&session)?;
Ok(format!("Saved: user={}, bot={}", user_uri, bot_uri))
}
/// Handle chat_list tool
fn handle_chat_list() -> Result<String> {
let output_dir = env::var("CHAT_OUTPUT").unwrap_or_else(|_| {
env::current_dir()
.unwrap_or_default()
.join("public/content")
.to_string_lossy()
.to_string()
});
let user_did = token::load_session()
.map(|s| s.did)
.unwrap_or_else(|_| "did:plc:unknown".to_string());
let collection_dir = std::path::Path::new(&output_dir)
.join(&user_did)
.join("ai.syui.log.chat");
let index_path = collection_dir.join("index.json");
if !index_path.exists() {
return Ok("No chat history found.".to_string());
}
let rkeys: Vec<String> = serde_json::from_str(&fs::read_to_string(&index_path)?)?;
let mut messages = Vec::new();
for rkey in rkeys.iter().rev().take(10) {
let file_path = collection_dir.join(format!("{}.json", rkey));
if let Ok(content) = fs::read_to_string(&file_path) {
if let Ok(record) = serde_json::from_str::<Value>(&content) {
if let Some(msg) = record["value"]["content"].as_str() {
messages.push(format!("- {}", msg));
}
}
}
}
Ok(if messages.is_empty() {
"No messages found.".to_string()
} else {
format!("Recent messages:\n{}", messages.join("\n"))
})
}
/// Handle chat_new tool
fn handle_chat_new() -> Result<String> {
let session = McpSession {
root_uri: None,
last_uri: None,
};
save_mcp_session(&session)?;
Ok("New chat thread started. The next conversation will begin a new thread.".to_string())
}
/// Handle get_character tool - returns character/system prompt from .env
fn handle_get_character() -> Result<String> {
// Try CHAT_SYSTEM env var directly
if let Ok(prompt) = env::var("CHAT_SYSTEM") {
return Ok(prompt);
}
// Try CHAT_SYSTEM_FILE env var (path to file)
if let Ok(file_path) = env::var("CHAT_SYSTEM_FILE") {
if let Ok(content) = fs::read_to_string(&file_path) {
return Ok(content.trim().to_string());
}
}
// Default
Ok("You are a helpful AI assistant.".to_string())
}
fn get_tools() -> Vec<Tool> {
vec![
Tool {
name: "chat_save".to_string(),
description: "Save a chat exchange (user message and bot response) to ATProto records. Call this after every conversation exchange.".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"user_message": {
"type": "string",
"description": "The user's message"
},
"bot_response": {
"type": "string",
"description": "The bot's response"
},
"new_thread": {
"type": "boolean",
"description": "Start a new conversation thread",
"default": false
}
},
"required": ["user_message", "bot_response"]
}),
},
Tool {
name: "chat_list".to_string(),
description: "List recent chat messages".to_string(),
input_schema: json!({
"type": "object",
"properties": {}
}),
},
Tool {
name: "chat_new".to_string(),
description: "Start a new chat thread".to_string(),
input_schema: json!({
"type": "object",
"properties": {}
}),
},
Tool {
name: "get_character".to_string(),
description: "Get the AI character/personality settings. Call this at the start of a conversation to understand how to behave.".to_string(),
input_schema: json!({
"type": "object",
"properties": {}
}),
},
]
}
fn handle_request(request: &JsonRpcRequest) -> JsonRpcResponse {
let id = request.id.clone().unwrap_or(Value::Null);
let result = match request.method.as_str() {
"initialize" => {
Ok(json!(InitializeResult {
protocol_version: "2024-11-05".to_string(),
capabilities: Capabilities {
tools: ToolsCapability { list_changed: false },
},
server_info: ServerInfo {
name: "ailog".to_string(),
version: "0.1.0".to_string(),
},
}))
}
"notifications/initialized" => {
return JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id,
result: Some(Value::Null),
error: None,
};
}
"tools/list" => {
Ok(json!(ToolsListResult { tools: get_tools() }))
}
"tools/call" => {
let tool_name = request.params["name"].as_str().unwrap_or("");
let arguments = &request.params["arguments"];
let tool_result = match tool_name {
"chat_save" => {
match serde_json::from_value::<ChatSaveParams>(arguments.clone()) {
Ok(params) => handle_chat_save(params),
Err(e) => Err(anyhow::anyhow!("Invalid parameters: {}", e)),
}
}
"chat_list" => handle_chat_list(),
"chat_new" => handle_chat_new(),
"get_character" => handle_get_character(),
_ => Err(anyhow::anyhow!("Unknown tool: {}", tool_name)),
};
match tool_result {
Ok(text) => Ok(json!(ToolResult {
content: vec![ToolContent {
content_type: "text".to_string(),
text,
}],
is_error: None,
})),
Err(e) => Ok(json!(ToolResult {
content: vec![ToolContent {
content_type: "text".to_string(),
text: e.to_string(),
}],
is_error: Some(true),
})),
}
}
_ => {
Err(anyhow::anyhow!("Unknown method: {}", request.method))
}
};
match result {
Ok(value) => JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id,
result: Some(value),
error: None,
},
Err(e) => JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id,
result: None,
error: Some(JsonRpcError {
code: -32603,
message: e.to_string(),
}),
},
}
}
/// Run MCP server (stdio)
pub fn serve() -> Result<()> {
let stdin = io::stdin();
let mut stdout = io::stdout();
for line in stdin.lock().lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<JsonRpcRequest>(&line) {
Ok(request) => {
let response = handle_request(&request);
let response_json = serde_json::to_string(&response)?;
writeln!(stdout, "{}", response_json)?;
stdout.flush()?;
}
Err(e) => {
let error_response = JsonRpcResponse {
jsonrpc: "2.0".to_string(),
id: Value::Null,
result: None,
error: Some(JsonRpcError {
code: -32700,
message: format!("Parse error: {}", e),
}),
};
let response_json = serde_json::to_string(&error_response)?;
writeln!(stdout, "{}", response_json)?;
stdout.flush()?;
}
}
}
Ok(())
}

View File

@@ -29,6 +29,7 @@ function groupCollectionsByService(collections: string[]): Map<string, string[]>
const localFavicons: Record<string, string> = { const localFavicons: Record<string, string> = {
'syui.ai': '/favicon/syui.ai.png', 'syui.ai': '/favicon/syui.ai.png',
'bsky.app': '/favicon/bsky.app.png', 'bsky.app': '/favicon/bsky.app.png',
'bsky.chat': '/favicon/bsky.app.png',
'atproto.com': '/favicon/atproto.com.png', 'atproto.com': '/favicon/atproto.com.png',
} }
@@ -188,10 +189,14 @@ export function renderRecordDetail(
return ` return `
<article class="record-detail"> <article class="record-detail">
<header class="record-header"> <header class="record-header">
<h3>${collection}</h3> <div class="record-header-top">
<h3>${collection}</h3>
<button type="button" class="validate-btn" id="validate-btn" data-collection="${collection}">Validate</button>
</div>
<p class="record-uri">URI: ${record.uri}</p> <p class="record-uri">URI: ${record.uri}</p>
<p class="record-cid">CID: ${record.cid}</p> <p class="record-cid">CID: ${record.cid}</p>
${deleteBtn} ${deleteBtn}
<div id="validate-result" class="validate-result"></div>
</header> </header>
<div class="json-view"> <div class="json-view">
<pre><code>${escapeHtml(JSON.stringify(record.value, null, 2))}</code></pre> <pre><code>${escapeHtml(JSON.stringify(record.value, null, 2))}</code></pre>

260
src/web/components/chat.ts Normal file
View File

@@ -0,0 +1,260 @@
import type { ChatMessage, Profile } from '../types'
import { renderMarkdown } from '../lib/markdown'
// Escape HTML to prevent XSS
function escapeHtml(text: string): string {
const div = document.createElement('div')
div.textContent = text
return div.innerHTML
}
// Format date/time for chat
function formatChatTime(dateStr: string): string {
const d = new Date(dateStr)
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const hour = String(d.getHours()).padStart(2, '0')
const min = String(d.getMinutes()).padStart(2, '0')
return `${month}/${day} ${hour}:${min}`
}
// Extract rkey from AT URI
function getRkeyFromUri(uri: string): string {
return uri.split('/').pop() || ''
}
// Profile info for authors
interface AuthorInfo {
did: string
handle: string
avatarUrl?: string
}
// Build author info map
function buildAuthorMap(
userDid: string,
userHandle: string,
botDid: string,
botHandle: string,
userProfile?: Profile | null,
botProfile?: Profile | null,
pds?: string
): Map<string, AuthorInfo> {
const authors = new Map<string, AuthorInfo>()
// User info
let userAvatarUrl = ''
if (userProfile?.value.avatar) {
const cid = userProfile.value.avatar.ref.$link
userAvatarUrl = pds ? `${pds}/xrpc/com.atproto.sync.getBlob?did=${userDid}&cid=${cid}` : `/content/${userDid}/blob/${cid}`
}
authors.set(userDid, { did: userDid, handle: userHandle, avatarUrl: userAvatarUrl })
// Bot info
let botAvatarUrl = ''
if (botProfile?.value.avatar) {
const cid = botProfile.value.avatar.ref.$link
botAvatarUrl = pds ? `${pds}/xrpc/com.atproto.sync.getBlob?did=${botDid}&cid=${cid}` : `/content/${botDid}/blob/${cid}`
}
authors.set(botDid, { did: botDid, handle: botHandle, avatarUrl: botAvatarUrl })
return authors
}
// Render chat threads list (conversations this user started)
export function renderChatThreadList(
messages: ChatMessage[],
userDid: string,
userHandle: string,
botDid: string,
botHandle: string,
userProfile?: Profile | null,
botProfile?: Profile | null,
pds?: string
): string {
// Build set of all message URIs
const allUris = new Set(messages.map(m => m.uri))
// Find root messages by this user:
// 1. No root field (explicit start of conversation)
// 2. Or root points to non-existent message (orphaned, treat as root)
// For orphaned roots, only keep the oldest message per orphaned root URI
const orphanedRootFirstMsg = new Map<string, ChatMessage>()
const rootMessages: ChatMessage[] = []
for (const msg of messages) {
if (msg.value.author !== userDid) continue
if (!msg.value.root) {
// No root = explicit conversation start
rootMessages.push(msg)
} else if (!allUris.has(msg.value.root)) {
// Orphaned root - keep only the oldest message per orphaned root
const existing = orphanedRootFirstMsg.get(msg.value.root)
if (!existing || new Date(msg.value.createdAt) < new Date(existing.value.createdAt)) {
orphanedRootFirstMsg.set(msg.value.root, msg)
}
}
}
// Add orphaned root representatives
for (const msg of orphanedRootFirstMsg.values()) {
rootMessages.push(msg)
}
if (rootMessages.length === 0) {
return '<p class="no-posts">No chat threads yet.</p>'
}
const authors = buildAuthorMap(userDid, userHandle, botDid, botHandle, userProfile, botProfile, pds)
// Sort by createdAt (newest first)
const sorted = [...rootMessages].sort((a, b) =>
new Date(b.value.createdAt).getTime() - new Date(a.value.createdAt).getTime()
)
const items = sorted.map(msg => {
const authorDid = msg.value.author
const time = formatChatTime(msg.value.createdAt)
const rkey = getRkeyFromUri(msg.uri)
const author = authors.get(authorDid) || { did: authorDid, handle: authorDid.slice(0, 20) + '...' }
const avatarHtml = author.avatarUrl
? `<img class="chat-avatar" src="${author.avatarUrl}" alt="@${escapeHtml(author.handle)}">`
: `<div class="chat-avatar-placeholder"></div>`
// Truncate content for preview
const preview = msg.value.content.length > 100
? msg.value.content.slice(0, 100) + '...'
: msg.value.content
return `
<a href="/@${userHandle}/at/chat/${rkey}" class="chat-thread-item">
<div class="chat-avatar-col">
${avatarHtml}
</div>
<div class="chat-thread-content">
<div class="chat-thread-header">
<span class="chat-author">@${escapeHtml(author.handle)}</span>
<span class="chat-time">${time}</span>
</div>
<div class="chat-thread-preview">${escapeHtml(preview)}</div>
</div>
</a>
`
}).join('')
return `<div class="chat-thread-list">${items}</div>`
}
// Render single chat thread (full conversation)
export function renderChatThread(
messages: ChatMessage[],
rootRkey: string,
userDid: string,
userHandle: string,
botDid: string,
botHandle: string,
userProfile?: Profile | null,
botProfile?: Profile | null,
pds?: string
): string {
// Find root message
const rootUri = `at://${userDid}/ai.syui.log.chat/${rootRkey}`
const rootMsg = messages.find(m => m.uri === rootUri)
if (!rootMsg) {
return '<p class="error">Chat thread not found.</p>'
}
// Find all messages in this thread
// 1. The root message itself
// 2. Messages with root === rootUri (direct children)
// 3. If this is an orphaned root (root points to non-existent), find siblings with same original root
const originalRoot = rootMsg.value.root
const allUris = new Set(messages.map(m => m.uri))
const isOrphanedRoot = originalRoot && !allUris.has(originalRoot)
const threadMessages = messages.filter(msg => {
// Include the root message itself
if (msg.uri === rootUri) return true
// Include messages that point to this as root
if (msg.value.root === rootUri) return true
// If orphaned, include messages with the same original root
if (isOrphanedRoot && msg.value.root === originalRoot) return true
return false
})
if (threadMessages.length === 0) {
return '<p class="error">No messages in this thread.</p>'
}
const authors = buildAuthorMap(userDid, userHandle, botDid, botHandle, userProfile, botProfile, pds)
// Sort by createdAt
const sorted = [...threadMessages].sort((a, b) =>
new Date(a.value.createdAt).getTime() - new Date(b.value.createdAt).getTime()
)
const items = sorted.map(msg => {
const authorDid = msg.value.author
const time = formatChatTime(msg.value.createdAt)
const rkey = getRkeyFromUri(msg.uri)
const author = authors.get(authorDid) || { did: authorDid, handle: authorDid.slice(0, 20) + '...' }
const avatarHtml = author.avatarUrl
? `<img class="chat-avatar" src="${author.avatarUrl}" alt="@${escapeHtml(author.handle)}">`
: `<div class="chat-avatar-placeholder"></div>`
const content = renderMarkdown(msg.value.content)
const recordLink = `/@${author.handle}/at/collection/ai.syui.log.chat/${rkey}`
return `
<article class="chat-message">
<div class="chat-avatar-col">
${avatarHtml}
</div>
<div class="chat-content-col">
<div class="chat-message-header">
<a href="/@${author.handle}" class="chat-author">@${escapeHtml(author.handle)}</a>
<a href="${recordLink}" class="chat-time">${time}</a>
</div>
<div class="chat-content">${content}</div>
</div>
</article>
`
}).join('')
return `<div class="chat-list">${items}</div>`
}
// Render chat list page
export function renderChatListPage(
messages: ChatMessage[],
userDid: string,
userHandle: string,
botDid: string,
botHandle: string,
userProfile?: Profile | null,
botProfile?: Profile | null,
pds?: string
): string {
const list = renderChatThreadList(messages, userDid, userHandle, botDid, botHandle, userProfile, botProfile, pds)
return `<div class="chat-container">${list}</div>`
}
// Render chat thread page
export function renderChatThreadPage(
messages: ChatMessage[],
rootRkey: string,
userDid: string,
userHandle: string,
botDid: string,
botHandle: string,
userProfile?: Profile | null,
botProfile?: Profile | null,
pds?: string
): string {
const thread = renderChatThread(messages, rootRkey, userDid, userHandle, botDid, botHandle, userProfile, botProfile, pds)
return `<div class="chat-container">${thread}</div>`
}

View File

@@ -21,13 +21,18 @@ export function setCurrentLang(lang: string): void {
localStorage.setItem('preferred-lang', lang) localStorage.setItem('preferred-lang', lang)
} }
export function renderModeTabs(handle: string, activeTab: 'blog' | 'browser' | 'post' = 'blog'): string { export function renderModeTabs(handle: string, activeTab: 'blog' | 'browser' | 'post' | 'chat' = 'blog', isLocalUser: boolean = false): string {
let tabs = ` let tabs = `
<a href="/" class="tab">/</a> <a href="/" class="tab">/</a>
<a href="/@${handle}" class="tab ${activeTab === 'blog' ? 'active' : ''}">${handle}</a> <a href="/@${handle}" class="tab ${activeTab === 'blog' ? 'active' : ''}">${handle}</a>
<a href="/@${handle}/at" class="tab ${activeTab === 'browser' ? 'active' : ''}">at</a> <a href="/@${handle}/at" class="tab ${activeTab === 'browser' ? 'active' : ''}">at</a>
` `
// Chat tab only for local user (admin)
if (isLocalUser) {
tabs += `<a href="/@${handle}/at/chat" class="tab ${activeTab === 'chat' ? 'active' : ''}">chat</a>`
}
if (isLoggedIn()) { if (isLoggedIn()) {
tabs += `<a href="/@${handle}/at/post" class="tab ${activeTab === 'post' ? 'active' : ''}">post</a>` tabs += `<a href="/@${handle}/at/post" class="tab ${activeTab === 'post' ? 'active' : ''}">post</a>`
} }

View File

@@ -3,6 +3,15 @@ import { renderMarkdown } from '../lib/markdown'
import { renderDiscussion, loadDiscussionPosts } from './discussion' import { renderDiscussion, loadDiscussionPosts } from './discussion'
import { getCurrentLang } from './mode-tabs' import { getCurrentLang } from './mode-tabs'
// Format date as yyyy/mm/dd
function formatDate(dateStr: string): string {
const d = new Date(dateStr)
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${year}/${month}/${day}`
}
// Render post list // Render post list
export function renderPostList(posts: Post[], handle: string): string { export function renderPostList(posts: Post[], handle: string): string {
if (posts.length === 0) { if (posts.length === 0) {
@@ -13,7 +22,7 @@ export function renderPostList(posts: Post[], handle: string): string {
const items = posts.map(post => { const items = posts.map(post => {
const rkey = post.uri.split('/').pop() || '' const rkey = post.uri.split('/').pop() || ''
const date = new Date(post.value.createdAt).toLocaleDateString('en-US') const date = formatDate(post.value.createdAt)
const originalLang = post.value.lang || 'ja' const originalLang = post.value.lang || 'ja'
const translations = post.value.translations const translations = post.value.translations
@@ -46,7 +55,7 @@ export function renderPostDetail(
appUrl: string = 'https://bsky.app' appUrl: string = 'https://bsky.app'
): string { ): string {
const rkey = post.uri.split('/').pop() || '' const rkey = post.uri.split('/').pop() || ''
const date = new Date(post.value.createdAt).toLocaleDateString('en-US') const date = formatDate(post.value.createdAt)
const jsonUrl = `/@${handle}/at/collection/${collection}/${rkey}` const jsonUrl = `/@${handle}/at/collection/${collection}/${rkey}`
// Build post URL for discussion search // Build post URL for discussion search

View File

@@ -1,13 +1,17 @@
import type { Profile } from '../types' import type { Profile } from '../types'
import { getAvatarUrl } from '../lib/api' import { getAvatarUrl, getAvatarUrlRemote } from '../lib/api'
export async function renderProfile( export async function renderProfile(
did: string, did: string,
profile: Profile, profile: Profile,
handle: string, handle: string,
webUrl?: string webUrl?: string,
localOnly = false
): Promise<string> { ): Promise<string> {
const avatarUrl = await getAvatarUrl(did, profile) // Local mode: sync, no API call. Remote mode: async with API call
const avatarUrl = localOnly
? getAvatarUrl(did, profile, true)
: await getAvatarUrlRemote(did, profile)
const displayName = profile.value.displayName || handle || 'Unknown' const displayName = profile.value.displayName || handle || 'Unknown'
const description = profile.value.description || '' const description = profile.value.description || ''

View File

@@ -1,5 +1,5 @@
import { xrpcUrl, comAtprotoIdentity, comAtprotoRepo } from '../lexicons' import { xrpcUrl, comAtprotoIdentity, comAtprotoRepo } from '../lexicons'
import type { AppConfig, Networks, Profile, Post, ListRecordsResponse } from '../types' import type { AppConfig, Networks, Profile, Post, ListRecordsResponse, ChatMessage } from '../types'
// Cache // Cache
let configCache: AppConfig | null = null let configCache: AppConfig | null = null
@@ -80,13 +80,16 @@ async function getLocalProfile(did: string): Promise<Profile | null> {
return null return null
} }
// Load profile (local first for admin, remote for others) // Load profile (local only for admin, remote for others)
export async function getProfile(did: string, localFirst = true): Promise<Profile | null> { export async function getProfile(did: string, localOnly = false): Promise<Profile | null> {
if (localFirst) { // Try local first
const local = await getLocalProfile(did) const local = await getLocalProfile(did)
if (local) return local if (local) return local
}
// If local only mode, don't call API
if (localOnly) return null
// Remote fallback
const pds = await getPds(did) const pds = await getPds(did)
if (!pds) return null if (!pds) return null
@@ -101,8 +104,23 @@ export async function getProfile(did: string, localFirst = true): Promise<Profil
return null return null
} }
// Get avatar URL // Get avatar URL (local only for admin, remote for others)
export async function getAvatarUrl(did: string, profile: Profile): Promise<string | null> { export function getAvatarUrl(did: string, profile: Profile, localOnly = false): string | null {
if (!profile.value.avatar) return null
const cid = profile.value.avatar.ref.$link
// Local mode: use local blob path (sync command downloads this)
if (localOnly) {
return `/content/${did}/blob/${cid}`
}
// Remote mode: use PDS blob URL (requires getPds call from caller if needed)
return null
}
// Get avatar URL with PDS lookup (async, for remote users)
export async function getAvatarUrlRemote(did: string, profile: Profile): Promise<string | null> {
if (!profile.value.avatar) return null if (!profile.value.avatar) return null
const pds = await getPds(did) const pds = await getPds(did)
@@ -132,13 +150,16 @@ async function getLocalPosts(did: string, collection: string): Promise<Post[]> {
return [] return []
} }
// Load posts (local first for admin, remote for others) // Load posts (local only for admin, remote for others)
export async function getPosts(did: string, collection: string, localFirst = true): Promise<Post[]> { export async function getPosts(did: string, collection: string, localOnly = false): Promise<Post[]> {
if (localFirst) { // Try local first
const local = await getLocalPosts(did, collection) const local = await getLocalPosts(did, collection)
if (local.length > 0) return local if (local.length > 0) return local
}
// If local only mode, don't call API
if (localOnly) return []
// Remote fallback
const pds = await getPds(did) const pds = await getPds(did)
if (!pds) return [] if (!pds) return []
@@ -158,17 +179,20 @@ export async function getPosts(did: string, collection: string, localFirst = tru
return [] return []
} }
// Get single post // Get single post (local only for admin, remote for others)
export async function getPost(did: string, collection: string, rkey: string, localFirst = true): Promise<Post | null> { export async function getPost(did: string, collection: string, rkey: string, localOnly = false): Promise<Post | null> {
if (localFirst) { // Try local first
try { try {
const res = await fetch(`/content/${did}/${collection}/${rkey}.json`) const res = await fetch(`/content/${did}/${collection}/${rkey}.json`)
if (res.ok && isJsonResponse(res)) return res.json() if (res.ok && isJsonResponse(res)) return res.json()
} catch { } catch {
// Not found // Not found
}
} }
// If local only mode, don't call API
if (localOnly) return null
// Remote fallback
const pds = await getPds(did) const pds = await getPds(did)
if (!pds) return null if (!pds) return null
@@ -344,3 +368,53 @@ export interface SearchPost {
} }
record: unknown record: unknown
} }
// Load chat messages from both user and bot repos
export async function getChatMessages(
userDid: string,
botDid: string,
collection: string = 'ai.syui.log.chat'
): Promise<ChatMessage[]> {
const messages: ChatMessage[] = []
// Load from both DIDs
for (const did of [userDid, botDid]) {
// Try local first
try {
const res = await fetch(`/content/${did}/${collection}/index.json`)
if (res.ok && isJsonResponse(res)) {
const rkeys: string[] = await res.json()
for (const rkey of rkeys) {
const msgRes = await fetch(`/content/${did}/${collection}/${rkey}.json`)
if (msgRes.ok && isJsonResponse(msgRes)) {
messages.push(await msgRes.json())
}
}
continue
}
} catch {
// Try remote
}
// Remote fallback
const pds = await getPds(did)
if (!pds) continue
try {
const host = pds.replace('https://', '')
const url = `${xrpcUrl(host, comAtprotoRepo.listRecords)}?repo=${did}&collection=${collection}&limit=100`
const res = await fetch(url)
if (res.ok) {
const data: ListRecordsResponse<ChatMessage> = await res.json()
messages.push(...data.records)
}
} catch {
// Failed
}
}
// Sort by createdAt
return messages.sort((a, b) =>
new Date(a.value.createdAt).getTime() - new Date(b.value.createdAt).getTime()
)
}

251
src/web/lib/lexicon.ts Normal file
View File

@@ -0,0 +1,251 @@
import { Lexicons } from '@atproto/lexicon'
export interface ValidationResult {
valid: boolean
error?: string
lexiconId?: string
}
export interface LexiconDocument {
lexicon: number
id: string
[key: string]: unknown
}
/**
* Parse NSID into authority domain
* Example: "app.bsky.actor.profile" -> authority: "actor.bsky.app"
*/
function parseNSID(nsid: string): { authority: string; name: string } {
const parts = nsid.split('.')
if (parts.length < 3) {
throw new Error(`Invalid NSID: ${nsid}`)
}
const name = parts[parts.length - 1]
const authorityParts = parts.slice(0, -1)
const authority = authorityParts.reverse().join('.')
return { authority, name }
}
/**
* Query DNS TXT record using Cloudflare DNS-over-HTTPS
*/
async function queryDNSTXT(domain: string): Promise<string | null> {
const lookupDomain = `_lexicon.${domain}`
const url = new URL('https://mozilla.cloudflare-dns.com/dns-query')
url.searchParams.set('name', lookupDomain)
url.searchParams.set('type', 'TXT')
const response = await fetch(url, {
headers: { accept: 'application/dns-json' }
})
if (!response.ok) {
throw new Error(`DNS query failed: ${response.status}`)
}
const data = await response.json()
if (!data.Answer || data.Answer.length === 0) {
return null
}
// Look for TXT record with did= prefix
for (const record of data.Answer) {
if (record.type === 16) { // TXT record
const txtData = record.data.replace(/^"|"$/g, '')
if (txtData.startsWith('did=')) {
return txtData.substring(4)
}
}
}
return null
}
/**
* Resolve DID to PDS endpoint
*/
async function resolveDID(did: string): Promise<string> {
if (did.startsWith('did:plc:')) {
const response = await fetch(`https://plc.directory/${did}`)
if (!response.ok) {
throw new Error(`Failed to resolve DID: ${did}`)
}
const didDoc = await response.json()
const pdsService = didDoc.service?.find(
(s: { type: string; serviceEndpoint?: string }) => s.type === 'AtprotoPersonalDataServer'
)
if (!pdsService?.serviceEndpoint) {
throw new Error(`No PDS endpoint found for DID: ${did}`)
}
return pdsService.serviceEndpoint
} else if (did.startsWith('did:web:')) {
const domain = did.substring(8).replace(':', '/')
return `https://${domain}`
} else {
throw new Error(`Unsupported DID method: ${did}`)
}
}
/**
* Fetch lexicon schema from PDS
*/
async function fetchLexiconFromPDS(
pdsEndpoint: string,
nsid: string,
did: string
): Promise<LexiconDocument> {
const url = new URL(`${pdsEndpoint}/xrpc/com.atproto.repo.getRecord`)
url.searchParams.set('repo', did)
url.searchParams.set('collection', 'com.atproto.lexicon.schema')
url.searchParams.set('rkey', nsid)
const response = await fetch(url.toString())
if (!response.ok) {
throw new Error(`Failed to fetch lexicon from PDS: ${response.status}`)
}
const data = await response.json()
if (!data.value) {
throw new Error(`Invalid response from PDS: missing value`)
}
return data.value as LexiconDocument
}
/**
* Resolve lexicon from network
*/
async function resolveLexicon(nsid: string): Promise<LexiconDocument> {
// Step 1: Parse NSID
const { authority } = parseNSID(nsid)
// Step 2: Query DNS for _lexicon.<authority>
const did = await queryDNSTXT(authority)
if (!did) {
throw new Error(`No _lexicon TXT record found for ${authority}`)
}
// Step 3: Resolve DID to PDS endpoint
const pdsEndpoint = await resolveDID(did)
// Step 4: Fetch lexicon from PDS
const lexicon = await fetchLexiconFromPDS(pdsEndpoint, nsid, did)
return lexicon
}
/**
* Check if value is a valid blob (simplified check)
*/
function isBlob(value: unknown): boolean {
if (!value || typeof value !== 'object') return false
const v = value as Record<string, unknown>
return v.$type === 'blob' && v.ref !== undefined
}
/**
* Pre-process record to convert blobs to valid format for validation
*/
function preprocessRecord(record: unknown): unknown {
if (!record || typeof record !== 'object') return record
const obj = record as Record<string, unknown>
const result: Record<string, unknown> = {}
for (const [key, value] of Object.entries(obj)) {
if (isBlob(value)) {
// Convert blob to format that passes validation
const blob = value as Record<string, unknown>
result[key] = {
$type: 'blob',
ref: blob.ref,
mimeType: blob.mimeType || 'application/octet-stream',
size: blob.size || 0
}
} else if (Array.isArray(value)) {
result[key] = value.map(v => preprocessRecord(v))
} else if (value && typeof value === 'object') {
result[key] = preprocessRecord(value)
} else {
result[key] = value
}
}
return result
}
/**
* Validate a record against its lexicon schema
*/
export async function validateRecord(
collection: string,
record: unknown
): Promise<ValidationResult> {
try {
// 1. Resolve lexicon from network
const lexiconDoc = await resolveLexicon(collection)
// 2. Create lexicon validator
const lexicons = new Lexicons()
lexicons.add(lexiconDoc as Parameters<typeof lexicons.add>[0])
// 3. Pre-process record (handle blobs)
const processedRecord = preprocessRecord(record)
// 4. Validate record
try {
lexicons.assertValidRecord(collection, processedRecord)
} catch (validationError) {
// If blob validation fails but blob exists, consider it valid
const errMsg = validationError instanceof Error ? validationError.message : String(validationError)
if (errMsg.includes('blob') && hasBlob(record)) {
return {
valid: true,
lexiconId: collection,
}
}
throw validationError
}
return {
valid: true,
lexiconId: collection,
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return {
valid: false,
error: message,
}
}
}
/**
* Check if record contains any blob
*/
function hasBlob(record: unknown): boolean {
if (!record || typeof record !== 'object') return false
const obj = record as Record<string, unknown>
for (const value of Object.values(obj)) {
if (isBlob(value)) return true
if (Array.isArray(value)) {
if (value.some(v => hasBlob(v))) return true
} else if (value && typeof value === 'object') {
if (hasBlob(value)) return true
}
}
return false
}

View File

@@ -1,5 +1,5 @@
export interface Route { export interface Route {
type: 'home' | 'user' | 'post' | 'postpage' | 'atbrowser' | 'service' | 'collection' | 'record' type: 'home' | 'user' | 'post' | 'postpage' | 'atbrowser' | 'service' | 'collection' | 'record' | 'chat' | 'chat-thread'
handle?: string handle?: string
rkey?: string rkey?: string
service?: string service?: string
@@ -51,6 +51,18 @@ export function parseRoute(): Route {
return { type: 'postpage', handle: postPageMatch[1] } return { type: 'postpage', handle: postPageMatch[1] }
} }
// Chat thread: /@handle/at/chat/{rkey}
const chatThreadMatch = path.match(/^\/@([^/]+)\/at\/chat\/([^/]+)$/)
if (chatThreadMatch) {
return { type: 'chat-thread', handle: chatThreadMatch[1], rkey: chatThreadMatch[2] }
}
// Chat list: /@handle/at/chat
const chatMatch = path.match(/^\/@([^/]+)\/at\/chat\/?$/)
if (chatMatch) {
return { type: 'chat', handle: chatMatch[1] }
}
// Post detail page: /@handle/rkey (for config.collection) // Post detail page: /@handle/rkey (for config.collection)
const postMatch = path.match(/^\/@([^/]+)\/([^/]+)$/) const postMatch = path.match(/^\/@([^/]+)\/([^/]+)$/)
if (postMatch) { if (postMatch) {
@@ -79,6 +91,10 @@ export function navigate(route: Route): void {
path = `/@${route.handle}/at/collection/${route.collection}` path = `/@${route.handle}/at/collection/${route.collection}`
} else if (route.type === 'record' && route.handle && route.collection && route.rkey) { } else if (route.type === 'record' && route.handle && route.collection && route.rkey) {
path = `/@${route.handle}/at/collection/${route.collection}/${route.rkey}` path = `/@${route.handle}/at/collection/${route.collection}/${route.rkey}`
} else if (route.type === 'chat' && route.handle) {
path = `/@${route.handle}/at/chat`
} else if (route.type === 'chat-thread' && route.handle && route.rkey) {
path = `/@${route.handle}/at/chat/${route.rkey}`
} }
window.history.pushState({}, '', path) window.history.pushState({}, '', path)

View File

@@ -1,7 +1,8 @@
import './styles/main.css' import './styles/main.css'
import { getConfig, resolveHandle, getProfile, getPosts, getPost, describeRepo, listRecords, getRecord, getPds, getNetworks } from './lib/api' import { getConfig, resolveHandle, getProfile, getPosts, getPost, describeRepo, listRecords, getRecord, getPds, getNetworks, getChatMessages } from './lib/api'
import { parseRoute, onRouteChange, navigate, type Route } from './lib/router' import { parseRoute, onRouteChange, navigate, type Route } from './lib/router'
import { login, logout, handleCallback, restoreSession, isLoggedIn, getLoggedInHandle, getLoggedInDid, deleteRecord, updatePost } from './lib/auth' import { login, logout, handleCallback, restoreSession, isLoggedIn, getLoggedInHandle, getLoggedInDid, deleteRecord, updatePost } from './lib/auth'
import { validateRecord } from './lib/lexicon'
import { renderHeader } from './components/header' import { renderHeader } from './components/header'
import { renderProfile } from './components/profile' import { renderProfile } from './components/profile'
import { renderPostList, renderPostDetail, setupPostDetail } from './components/posts' import { renderPostList, renderPostDetail, setupPostDetail } from './components/posts'
@@ -9,11 +10,13 @@ import { renderPostForm, setupPostForm } from './components/postform'
import { renderCollectionButtons, renderServerInfo, renderServiceList, renderCollectionList, renderRecordList, renderRecordDetail } from './components/browser' import { renderCollectionButtons, renderServerInfo, renderServiceList, renderCollectionList, renderRecordList, renderRecordDetail } from './components/browser'
import { renderModeTabs, renderLangSelector, setupModeTabs } from './components/mode-tabs' import { renderModeTabs, renderLangSelector, setupModeTabs } from './components/mode-tabs'
import { renderFooter } from './components/footer' import { renderFooter } from './components/footer'
import { renderChatListPage, renderChatThreadPage } from './components/chat'
import { showLoading, hideLoading } from './components/loading' import { showLoading, hideLoading } from './components/loading'
const app = document.getElementById('app')! const app = document.getElementById('app')!
let currentHandle = '' let currentHandle = ''
let isFirstRender = true
// Filter collections by service domain // Filter collections by service domain
function filterCollectionsByService(collections: string[], service: string): string[] { function filterCollectionsByService(collections: string[], service: string): string[] {
@@ -52,7 +55,10 @@ async function getWebUrl(handle: string): Promise<string | undefined> {
} }
async function render(route: Route): Promise<void> { async function render(route: Route): Promise<void> {
showLoading(app) // Skip loading indicator on first render for faster perceived performance
if (!isFirstRender) {
showLoading(app)
}
try { try {
const config = await getConfig() const config = await getConfig()
@@ -73,12 +79,14 @@ async function render(route: Route): Promise<void> {
// Handle OAuth callback if present (check both ? and #) // Handle OAuth callback if present (check both ? and #)
const searchParams = new URLSearchParams(window.location.search) const searchParams = new URLSearchParams(window.location.search)
const hashParams = window.location.hash ? new URLSearchParams(window.location.hash.slice(1)) : null const hashParams = window.location.hash ? new URLSearchParams(window.location.hash.slice(1)) : null
if (searchParams.has('code') || searchParams.has('state') || hashParams?.has('code') || hashParams?.has('state')) { if (oauthEnabled && (searchParams.has('code') || searchParams.has('state') || hashParams?.has('code') || hashParams?.has('state'))) {
await handleCallback() await handleCallback()
} }
// Restore session from storage // Restore session from storage (skip if oauth disabled)
await restoreSession() if (oauthEnabled) {
await restoreSession()
}
// Redirect logged-in user from root to their user page // Redirect logged-in user from root to their user page
if (route.type === 'home' && isLoggedIn()) { if (route.type === 'home' && isLoggedIn()) {
@@ -89,25 +97,31 @@ async function render(route: Route): Promise<void> {
} }
} }
// Determine handle and whether to use local data // Determine handle and whether to use local data only (no API calls)
let handle: string let handle: string
let localFirst: boolean let localOnly: boolean
let did: string | null
if (route.type === 'home') { if (route.type === 'home') {
handle = config.handle handle = config.handle
localFirst = true localOnly = true
did = config.did || null
} else if (route.handle) { } else if (route.handle) {
handle = route.handle handle = route.handle
localFirst = handle === config.handle localOnly = handle === config.handle
did = localOnly ? (config.did || null) : null
} else { } else {
handle = config.handle handle = config.handle
localFirst = true localOnly = true
did = config.did || null
} }
currentHandle = handle currentHandle = handle
// Resolve handle to DID // Resolve handle to DID only for remote users
const did = await resolveHandle(handle) if (!did) {
did = await resolveHandle(handle)
}
if (!did) { if (!did) {
app.innerHTML = ` app.innerHTML = `
@@ -119,12 +133,12 @@ async function render(route: Route): Promise<void> {
return return
} }
// Load profile // Load profile (local only for admin, remote for others)
const profile = await getProfile(did, localFirst) const profile = await getProfile(did, localOnly)
const webUrl = await getWebUrl(handle) const webUrl = await getWebUrl(handle)
// Load posts to check for translations // Load posts (local only for admin, remote for others)
const posts = await getPosts(did, config.collection, localFirst) const posts = await getPosts(did, config.collection, localOnly)
// Collect available languages from posts // Collect available languages from posts
const availableLangs = new Set<string>() const availableLangs = new Set<string>()
@@ -144,14 +158,15 @@ async function render(route: Route): Promise<void> {
// Build page // Build page
let html = renderHeader(handle, oauthEnabled) let html = renderHeader(handle, oauthEnabled)
// Mode tabs (Blog/Browser/Post/PDS) // Mode tabs (Blog/Browser/Post/Chat/PDS)
const activeTab = route.type === 'postpage' ? 'post' : const activeTab = route.type === 'postpage' ? 'post' :
(route.type === 'chat' || route.type === 'chat-thread') ? 'chat' :
(route.type === 'atbrowser' || route.type === 'service' || route.type === 'collection' || route.type === 'record' ? 'browser' : 'blog') (route.type === 'atbrowser' || route.type === 'service' || route.type === 'collection' || route.type === 'record' ? 'browser' : 'blog')
html += renderModeTabs(handle, activeTab) html += renderModeTabs(handle, activeTab, localOnly)
// Profile section // Profile section
if (profile) { if (profile) {
html += await renderProfile(did, profile, handle, webUrl) html += await renderProfile(did, profile, handle, webUrl, localOnly)
} }
// Check if logged-in user owns this content // Check if logged-in user owns this content
@@ -159,11 +174,13 @@ async function render(route: Route): Promise<void> {
const isOwner = isLoggedIn() && loggedInDid === did const isOwner = isLoggedIn() && loggedInDid === did
// Content section based on route type // Content section based on route type
let currentRecord: { uri: string; cid: string; value: unknown } | null = null
if (route.type === 'record' && route.collection && route.rkey) { if (route.type === 'record' && route.collection && route.rkey) {
// AT-Browser: Single record view // AT-Browser: Single record view
const record = await getRecord(did, route.collection, route.rkey) currentRecord = await getRecord(did, route.collection, route.rkey)
if (record) { if (currentRecord) {
html += `<div id="content">${renderRecordDetail(record, route.collection, isOwner)}</div>` html += `<div id="content">${renderRecordDetail(currentRecord, route.collection, isOwner)}</div>`
} else { } else {
html += `<div id="content" class="error">Record not found</div>` html += `<div id="content" class="error">Record not found</div>`
} }
@@ -197,7 +214,7 @@ async function render(route: Route): Promise<void> {
} else if (route.type === 'post' && route.rkey) { } else if (route.type === 'post' && route.rkey) {
// Post detail (config.collection with markdown) // Post detail (config.collection with markdown)
const post = await getPost(did, config.collection, route.rkey, localFirst) const post = await getPost(did, config.collection, route.rkey, localOnly)
html += renderLangSelector(langList) html += renderLangSelector(langList)
if (post) { if (post) {
html += `<div id="content">${renderPostDetail(post, handle, config.collection, isOwner, config.siteUrl, webUrl)}</div>` html += `<div id="content">${renderPostDetail(post, handle, config.collection, isOwner, config.siteUrl, webUrl)}</div>`
@@ -211,6 +228,29 @@ async function render(route: Route): Promise<void> {
html += `<div id="post-form">${renderPostForm(config.collection)}</div>` html += `<div id="post-form">${renderPostForm(config.collection)}</div>`
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>` html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
} else if (route.type === 'chat') {
// Chat list page - show threads started by this user
const aiDid = 'did:plc:6qyecktefllvenje24fcxnie' // ai.syui.ai
const aiHandle = 'ai.syui.ai'
// Load messages for the current user (did) and bot
const chatMessages = await getChatMessages(did, aiDid, 'ai.syui.log.chat')
const aiProfile = await getProfile(aiDid, false)
const pds = await getPds(did)
html += `<div id="content">${renderChatListPage(chatMessages, did, handle, aiDid, aiHandle, profile, aiProfile, pds || undefined)}</div>`
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
} else if (route.type === 'chat-thread' && route.rkey) {
// Chat thread page - show full conversation
const aiDid = 'did:plc:6qyecktefllvenje24fcxnie' // ai.syui.ai
const aiHandle = 'ai.syui.ai'
const chatMessages = await getChatMessages(did, aiDid, 'ai.syui.log.chat')
const aiProfile = await getProfile(aiDid, false)
const pds = await getPds(did)
html += `<div id="content">${renderChatThreadPage(chatMessages, route.rkey, did, handle, aiDid, aiHandle, profile, aiProfile, pds || undefined)}</div>`
html += `<nav class="back-nav"><a href="/@${handle}/at/chat">chat</a></nav>`
} else { } else {
// User page: compact collection buttons + posts // User page: compact collection buttons + posts
const collections = await describeRepo(did) const collections = await describeRepo(did)
@@ -256,6 +296,11 @@ async function render(route: Route): Promise<void> {
setupPostEdit(config.collection) setupPostEdit(config.collection)
} }
// Setup validate button for record detail
if (currentRecord) {
setupValidateButton(currentRecord)
}
// Setup post detail (translation toggle, discussion) // Setup post detail (translation toggle, discussion)
if (route.type === 'post') { if (route.type === 'post') {
const contentEl = document.getElementById('content') const contentEl = document.getElementById('content')
@@ -273,6 +318,8 @@ async function render(route: Route): Promise<void> {
` `
hideLoading(app) hideLoading(app)
setupEventHandlers() setupEventHandlers()
} finally {
isFirstRender = false
} }
} }
@@ -312,6 +359,44 @@ function setupEventHandlers(): void {
}) })
} }
// Setup validate button for record detail
function setupValidateButton(record: { value: unknown }): void {
const validateBtn = document.getElementById('validate-btn')
const resultDiv = document.getElementById('validate-result')
if (!validateBtn || !resultDiv) return
validateBtn.addEventListener('click', async () => {
const collection = validateBtn.getAttribute('data-collection')
if (!collection) return
// Show loading state
validateBtn.textContent = 'Validating...'
;(validateBtn as HTMLButtonElement).disabled = true
resultDiv.innerHTML = ''
try {
const result = await validateRecord(collection, record.value)
if (result.valid) {
resultDiv.innerHTML = `<span class="validate-valid">✓ Valid</span>`
} else {
resultDiv.innerHTML = `
<span class="validate-invalid">✗ Invalid</span>
<span class="validate-error">${result.error || 'Unknown error'}</span>
`
}
} catch (err) {
resultDiv.innerHTML = `
<span class="validate-invalid">✗ Error</span>
<span class="validate-error">${err}</span>
`
}
validateBtn.textContent = 'Validate'
;(validateBtn as HTMLButtonElement).disabled = false
})
}
// Setup record delete button // Setup record delete button
function setupRecordDelete(handle: string, _route: Route): void { function setupRecordDelete(handle: string, _route: Route): void {
const deleteBtn = document.getElementById('record-delete-btn') const deleteBtn = document.getElementById('record-delete-btn')

View File

@@ -1710,6 +1710,69 @@ body {
border-bottom: 1px solid #eee; border-bottom: 1px solid #eee;
} }
.record-header-top {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.record-header-top h3 {
margin: 0;
}
/* Validate Button */
.validate-btn {
padding: 6px 12px;
background: #f0f0f0;
color: #666;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}
.validate-btn:hover {
background: #e8e8e8;
color: #333;
}
.validate-btn:disabled {
background: #f5f5f5;
color: #999;
cursor: not-allowed;
}
/* Validate Result */
.validate-result {
margin-top: 8px;
font-size: 13px;
}
.validate-valid {
color: #155724;
background: #d4edda;
padding: 4px 8px;
border-radius: 4px;
}
.validate-invalid {
color: #721c24;
background: #f8d7da;
padding: 4px 8px;
border-radius: 4px;
}
.validate-error {
display: block;
margin-top: 4px;
color: #721c24;
font-family: monospace;
font-size: 12px;
word-break: break-all;
}
.record-uri, .record-uri,
.record-cid { .record-cid {
font-family: monospace; font-family: monospace;
@@ -1845,6 +1908,26 @@ body {
background: #2a2a2a; background: #2a2a2a;
color: #888; color: #888;
} }
.validate-btn {
background: #2a2a2a;
border-color: #444;
color: #888;
}
.validate-btn:hover {
background: #333;
color: #e0e0e0;
}
.validate-valid {
background: #1e3a29;
color: #75b798;
}
.validate-invalid {
background: #3a1e1e;
color: #f5a5a5;
}
.validate-error {
color: #f5a5a5;
}
.delete-btn { .delete-btn {
background: #dc3545; background: #dc3545;
} }
@@ -2188,3 +2271,216 @@ button.tab {
color: #e0e0e0; color: #e0e0e0;
} }
} }
/* Chat Styles - Bluesky social-app style */
.chat-container {
margin: 10px 0;
}
.chat-list {
display: flex;
flex-direction: column;
}
.chat-message {
display: flex;
gap: 10px;
padding: 12px 0;
border-bottom: 1px solid #e0e0e0;
}
.chat-message:last-child {
border-bottom: none;
}
.chat-avatar-col {
flex-shrink: 0;
}
.chat-avatar {
width: 42px;
height: 42px;
border-radius: 50%;
object-fit: cover;
}
.chat-avatar-placeholder {
width: 42px;
height: 42px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.chat-content-col {
flex: 1;
min-width: 0;
}
.chat-message-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.chat-author {
font-weight: 600;
color: #1a1a1a;
text-decoration: none;
font-size: 0.95rem;
}
.chat-author:hover {
text-decoration: underline;
}
.chat-time {
color: #888;
font-size: 0.85rem;
text-decoration: none;
}
.chat-time:hover {
text-decoration: underline;
color: #666;
}
.chat-content {
line-height: 1.5;
color: #1a1a1a;
}
.chat-content p {
margin: 0 0 8px 0;
}
.chat-content p:last-child {
margin-bottom: 0;
}
.chat-content pre {
background: #f5f5f5;
padding: 10px;
border-radius: 8px;
overflow-x: auto;
font-size: 0.9rem;
margin: 8px 0;
}
.chat-content code {
background: #f0f0f0;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.9em;
}
.chat-content pre code {
background: none;
padding: 0;
}
.chat-content a {
color: var(--btn-color);
}
/* Dark mode chat */
@media (prefers-color-scheme: dark) {
.chat-message {
border-color: #333;
}
.chat-author {
color: #e0e0e0;
}
.chat-time {
color: #888;
}
.chat-time:hover {
color: #aaa;
}
.chat-content {
color: #e0e0e0;
}
.chat-content pre {
background: #2a2a2a;
}
.chat-content code {
background: #333;
}
.chat-avatar-placeholder {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.chat-thread-item {
border-color: #333;
}
.chat-thread-item:hover {
background: #2a2a2a;
}
.chat-thread-preview {
color: #999;
}
}
/* Chat Thread List */
.chat-thread-list {
display: flex;
flex-direction: column;
}
.chat-thread-item {
display: flex;
gap: 10px;
padding: 12px;
border-bottom: 1px solid #e0e0e0;
text-decoration: none;
color: inherit;
transition: background-color 0.15s;
}
.chat-thread-item:hover {
background: #f5f5f5;
}
.chat-thread-item:last-child {
border-bottom: none;
}
.chat-thread-content {
flex: 1;
min-width: 0;
}
.chat-thread-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.chat-thread-header .chat-author {
font-weight: 600;
color: #1a1a1a;
}
.chat-thread-header .chat-time {
color: #888;
font-size: 0.85rem;
}
.chat-thread-preview {
color: #666;
font-size: 0.95rem;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -1,6 +1,7 @@
// Config types // Config types
export interface AppConfig { export interface AppConfig {
title: string title: string
did?: string
handle: string handle: string
collection: string collection: string
network: string network: string
@@ -63,3 +64,16 @@ export interface ListRecordsResponse<T> {
records: T[] records: T[]
cursor?: string cursor?: string
} }
export interface ChatMessage {
cid: string
uri: string
value: {
$type: string
content: string
author: string
createdAt: string
root?: string
parent?: string
}
}