Compare commits
10 Commits
min
...
00b618eb16
| Author | SHA1 | Date | |
|---|---|---|---|
|
00b618eb16
|
|||
|
837b204643
|
|||
|
39308cdc85
|
|||
|
a77dde0366
|
|||
|
1d3aa51fb6
|
|||
|
6f5290753d
|
|||
|
e8cf46465f
|
|||
|
676c8942de
|
|||
|
623fe61468
|
|||
|
ce00222537
|
12
.env.example
Normal file
12
.env.example
Normal 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
36
.github/workflows/cf-pages.yml
vendored
Normal 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
25
Cargo.toml
Normal 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"
|
||||||
72
lexicons/ai.syui.log.chat.json
Normal file
72
lexicons/ai.syui.log.chat.json
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
{
|
||||||
|
"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."
|
||||||
|
},
|
||||||
|
"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 message 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 chat message.",
|
||||||
|
"properties": {
|
||||||
|
"content": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 100000,
|
||||||
|
"maxGraphemes": 10000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
68
lexicons/ai.syui.log.post.json
Normal file
68
lexicons/ai.syui.log.post.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,14 +4,16 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
"prebuild": "ailog index 2>/dev/null || true",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"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",
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"cid": "bafyreif3v4pzdo5penoptahlmqmfdatpoujm7vg4wg7mndsyhtgwdwdhfa",
|
||||||
|
"uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat/d3dmreieiynnd",
|
||||||
|
"value": {
|
||||||
|
"$type": "ai.syui.log.chat",
|
||||||
|
"author": "did:plc:6qyecktefllvenje24fcxnie",
|
||||||
|
"content": "ailogのコンセプトね。\n\n**「データはATProtoに、サーバーには置かない」**\n\nこれが核心だよ。\n\n従来のブログは、サーバーのDBに記事を保存する。でもailogは違う。\n\n```\nユーザーのPDS(ATProto)← 記事データ\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",
|
||||||
|
"translations": {
|
||||||
|
"en": {
|
||||||
|
"content": "The core concept of ailog:\n\n**\"Keep data in ATProto - don't store it on the server.\"**\n\nThat's the essence of it.\n\nTraditional blogs save articles on the server's database. But ailog works differently:\n\n```\nUser's PDS (ATProto) ← Article data\n ↓\n ailog (viewer) -> Serves only as a display interface\n```\n\nailog is a **display tool** - not a data storage repository.\n\nWhat this means: your blog posts exist in **your own PDS**. Even if the service disappears, your data remains. That's ATProto's decentralized philosophy in action.\n\nAnyone can access `/@handle` without registration. If you have an ATProto account, that's instantly your personal blog.\n\nPretty cool, right? Any particular aspects you'd like to discuss further?"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"cid": "bafyreicaepldknwroe6tqh6pwrvl7c5sdv6guxkwllmf4emza73r2ygaxi",
|
||||||
|
"uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat/evknoqtr7uyey",
|
||||||
|
"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",
|
||||||
|
"translations": {
|
||||||
|
"en": {
|
||||||
|
"content": "The developer's activities and marketing strategies... huh.\n\nWhich aspect are we talking about here?\n\n1. **Introducing the activities of ailog's developer** (syui)\n2. **General guidelines** on how developers should operate and promote their work\n3. **Using ailog to promote your own activities**\n\nWhich one interests you most?"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
[
|
||||||
|
"kr5ig7vlgl276",
|
||||||
|
"evknoqtr7uyey",
|
||||||
|
"d3dmreieiynnd"
|
||||||
|
]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"cid": "bafyreiccf2dkcob64k3xvyhezrppnu6z4omaj4h34tw7ujj65wcnztvhpa",
|
||||||
|
"uri": "at://did:plc:6qyecktefllvenje24fcxnie/ai.syui.log.chat/kr5ig7vlgl276",
|
||||||
|
"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",
|
||||||
|
"translations": {
|
||||||
|
"en": {
|
||||||
|
"content": "I see. What's good is that you're doing some self-reflection.\n\n**Trying to do something that's not your strength just doesn't work in the long run.** That's true.\n\nHere's my assessment:\n\n**Option 1 is the best choice** in my opinion.\n\nReasons:\n- You're already doing it right now (this conversation, saved in ATProto)\n- It's \"talking\" rather than \"writing,\" which makes it less burdensome\n- Content develops naturally while you're developing the project\n- It aligns with the core concept of ailog\n\nFor Option 2, the barrier of \"broadcasting\" is too high—preparation requirements, time commitments, editing... there's a strong chance of giving up.\n\nOption 3 is already where you've admitted it's not your strong suit. Let's pass on that one.\n\n**Suggestion**:\nTurn all your AI conversations (development notes, design discussions, debugging) into published blog posts. This could easily be done with ailog, right?\n\nWhat do you think?"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
[
|
||||||
|
"p6mxoi2x2ooz6",
|
||||||
|
"qbuquaswgxo36",
|
||||||
|
"wigv2qnon7pmg"
|
||||||
|
]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"cid": "bafyreip6mxoi2x2ooz6",
|
||||||
|
"uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/p6mxoi2x2ooz6",
|
||||||
|
"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",
|
||||||
|
"translations": {
|
||||||
|
"en": {
|
||||||
|
"content": "I believe it's important for developers to regularly post and share about their daily activities.\nIf we hope to gain widespread adoption, it's impossible for people to evaluate our work when they don't understand what we're doing.\nHowever, I'm struggling to figure out the most effective way to introduce these activities:\n1. Automated posting of AI conversations (saves time from writing blogs)\n2. Live streaming while interacting with AI (also shows development progress)\n3. Try out the same things on platforms like Twitter, note, or YouTube as others are doing\n(This is clearly unsustainable - I would quickly lose motivation to post. There's barely anything to write about or talk about to begin with.)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"cid": "bafyreiqbuquaswgxo36",
|
||||||
|
"uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/qbuquaswgxo36",
|
||||||
|
"value": {
|
||||||
|
"$type": "ai.syui.log.chat",
|
||||||
|
"author": "did:plc:vzsvtbtbnwn22xjqhcu3vd6y",
|
||||||
|
"content": "開発者の活動と宣伝",
|
||||||
|
"createdAt": "2026-01-20T09:49:05.242Z",
|
||||||
|
"translations": {
|
||||||
|
"en": {
|
||||||
|
"content": "Developer Activities and Promotions"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"cid": "bafyreiwigv2qnon7pmg",
|
||||||
|
"uri": "at://did:plc:vzsvtbtbnwn22xjqhcu3vd6y/ai.syui.log.chat/wigv2qnon7pmg",
|
||||||
|
"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",
|
||||||
|
"translations": {
|
||||||
|
"en": {
|
||||||
|
"content": "Try giving a concise introduction to the concept behind ailog."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[
|
||||||
|
"3mchqlshygs2s"
|
||||||
|
]
|
||||||
@@ -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 |
@@ -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
383
readme.md
@@ -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=gpt-oss-20b
|
||||||
|
```
|
||||||
|
|
||||||
|
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
138
src/commands/auth.rs
Normal 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
34
src/commands/did.rs
Normal 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
265
src/commands/gen.rs
Normal 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
|
||||||
|
}
|
||||||
130
src/commands/index.rs
Normal file
130
src/commands/index.rs
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Rebuild index.json files for all collections in content directory
|
||||||
|
pub fn run(content_dir: &Path) -> Result<()> {
|
||||||
|
if !content_dir.exists() {
|
||||||
|
println!("Content directory not found: {}", content_dir.display());
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut total_updated = 0;
|
||||||
|
let mut total_created = 0;
|
||||||
|
|
||||||
|
// Iterate through DID directories
|
||||||
|
for did_entry in fs::read_dir(content_dir)? {
|
||||||
|
let did_entry = did_entry?;
|
||||||
|
let did_path = did_entry.path();
|
||||||
|
|
||||||
|
if !did_path.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let did_name = did_path.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
// Skip non-DID directories
|
||||||
|
if !did_name.starts_with("did:") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iterate through collection directories
|
||||||
|
for col_entry in fs::read_dir(&did_path)? {
|
||||||
|
let col_entry = col_entry?;
|
||||||
|
let col_path = col_entry.path();
|
||||||
|
|
||||||
|
if !col_path.is_dir() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let col_name = col_path.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
// Collect all rkeys from .json files (excluding special files)
|
||||||
|
let mut rkeys: Vec<String> = Vec::new();
|
||||||
|
let mut rkey_times: HashMap<String, std::time::SystemTime> = HashMap::new();
|
||||||
|
|
||||||
|
for file_entry in fs::read_dir(&col_path)? {
|
||||||
|
let file_entry = file_entry?;
|
||||||
|
let file_path = file_entry.path();
|
||||||
|
|
||||||
|
if !file_path.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let filename = file_path.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
|
// Skip non-json and special files
|
||||||
|
if !filename.ends_with(".json") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if filename == "index.json" || filename == "describe.json" || filename == "self.json" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract rkey from filename
|
||||||
|
let rkey = filename.trim_end_matches(".json").to_string();
|
||||||
|
|
||||||
|
// Get file modification time for sorting
|
||||||
|
if let Ok(metadata) = file_entry.metadata() {
|
||||||
|
if let Ok(modified) = metadata.modified() {
|
||||||
|
rkey_times.insert(rkey.clone(), modified);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rkeys.push(rkey);
|
||||||
|
}
|
||||||
|
|
||||||
|
if rkeys.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by modification time (oldest first) or alphabetically
|
||||||
|
rkeys.sort_by(|a, b| {
|
||||||
|
match (rkey_times.get(a), rkey_times.get(b)) {
|
||||||
|
(Some(ta), Some(tb)) => ta.cmp(tb),
|
||||||
|
_ => a.cmp(b),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check existing index.json
|
||||||
|
let index_path = col_path.join("index.json");
|
||||||
|
let existing: Vec<String> = if index_path.exists() {
|
||||||
|
fs::read_to_string(&index_path)
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| serde_json::from_str(&s).ok())
|
||||||
|
.unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Compare and update if different
|
||||||
|
if existing != rkeys {
|
||||||
|
fs::write(&index_path, serde_json::to_string_pretty(&rkeys)?)?;
|
||||||
|
|
||||||
|
if existing.is_empty() && !index_path.exists() {
|
||||||
|
println!(" Created: {}/{}/index.json ({} records)", did_name, col_name, rkeys.len());
|
||||||
|
total_created += 1;
|
||||||
|
} else {
|
||||||
|
println!(" Updated: {}/{}/index.json ({} -> {} records)",
|
||||||
|
did_name, col_name, existing.len(), rkeys.len());
|
||||||
|
total_updated += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if total_created == 0 && total_updated == 0 {
|
||||||
|
println!("All index.json files are up to date.");
|
||||||
|
} else {
|
||||||
|
println!("\nDone: {} created, {} updated", total_created, total_updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
10
src/commands/lang.rs
Normal file
10
src/commands/lang.rs
Normal 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
|
||||||
|
}
|
||||||
7
src/commands/mod.rs
Normal file
7
src/commands/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
pub mod auth;
|
||||||
|
pub mod token;
|
||||||
|
pub mod post;
|
||||||
|
pub mod gen;
|
||||||
|
pub mod lang;
|
||||||
|
pub mod did;
|
||||||
|
pub mod index;
|
||||||
466
src/commands/post.rs
Normal file
466
src/commands/post.rs
Normal 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
74
src/commands/token.rs
Normal 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
303
src/lexicons/mod.rs
Normal 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
395
src/lms/chat.rs
Normal 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
2
src/lms/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod chat;
|
||||||
|
pub mod translate;
|
||||||
244
src/lms/translate.rs
Normal file
244
src/lms/translate.rs
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
207
src/main.rs
Normal file
207
src/main.rs
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
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,
|
||||||
|
|
||||||
|
/// Rebuild index.json files for content collections
|
||||||
|
#[command(alias = "i")]
|
||||||
|
Index {
|
||||||
|
/// Content directory
|
||||||
|
#[arg(short, long, default_value = "public/content")]
|
||||||
|
dir: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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()?;
|
||||||
|
}
|
||||||
|
Commands::Index { dir } => {
|
||||||
|
commands::index::run(std::path::Path::new(&dir))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
516
src/mcp/mod.rs
Normal file
516
src/mcp/mod.rs
Normal file
@@ -0,0 +1,516 @@
|
|||||||
|
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)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
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: String::new(), // CID is assigned by PDS on push
|
||||||
|
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 index_content = fs::read_to_string(&index_path).unwrap_or_else(|_| "[]".to_string());
|
||||||
|
serde_json::from_str(&index_content).unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
if !rkeys.contains(&rkey) {
|
||||||
|
rkeys.push(rkey.clone());
|
||||||
|
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,
|
||||||
|
¶ms.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,
|
||||||
|
¶ms.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(())
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
<div class="record-header-top">
|
||||||
<h3>${collection}</h3>
|
<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>
|
||||||
|
|||||||
275
src/web/components/chat.ts
Normal file
275
src/web/components/chat.ts
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
import type { ChatMessage, Profile } from '../types'
|
||||||
|
import { renderMarkdown } from '../lib/markdown'
|
||||||
|
import { getCurrentLang } from './mode-tabs'
|
||||||
|
|
||||||
|
// Get translated content for a chat message
|
||||||
|
function getTranslatedContent(msg: ChatMessage): string {
|
||||||
|
const currentLang = getCurrentLang()
|
||||||
|
const originalLang = msg.value.lang || 'ja'
|
||||||
|
const translations = msg.value.translations
|
||||||
|
|
||||||
|
if (translations && currentLang !== originalLang && translations[currentLang]) {
|
||||||
|
return translations[currentLang].content || msg.value.content
|
||||||
|
}
|
||||||
|
return msg.value.content
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (use translated content)
|
||||||
|
const displayContent = getTranslatedContent(msg)
|
||||||
|
const preview = displayContent.length > 100
|
||||||
|
? displayContent.slice(0, 100) + '...'
|
||||||
|
: displayContent
|
||||||
|
|
||||||
|
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 displayContent = getTranslatedContent(msg)
|
||||||
|
const content = renderMarkdown(displayContent)
|
||||||
|
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>`
|
||||||
|
}
|
||||||
@@ -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>`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 || ''
|
||||||
|
|
||||||
|
|||||||
@@ -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
251
src/web/lib/lexicon.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
161
src/web/main.ts
161
src/web/main.ts
@@ -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> {
|
||||||
|
// Skip loading indicator on first render for faster perceived performance
|
||||||
|
if (!isFirstRender) {
|
||||||
showLoading(app)
|
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)
|
||||||
|
if (oauthEnabled) {
|
||||||
await restoreSession()
|
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,14 +133,14 @@ 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 (used for non-chat pages)
|
||||||
const availableLangs = new Set<string>()
|
const availableLangs = new Set<string>()
|
||||||
for (const post of posts) {
|
for (const post of posts) {
|
||||||
// Add original language (default: ja for Japanese posts)
|
// Add original language (default: ja for Japanese posts)
|
||||||
@@ -139,19 +153,20 @@ async function render(route: Route): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const langList = Array.from(availableLangs)
|
let langList = Array.from(availableLangs)
|
||||||
|
|
||||||
// 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,59 @@ 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)
|
||||||
|
|
||||||
|
// Collect available languages from chat messages
|
||||||
|
const chatLangs = new Set<string>()
|
||||||
|
for (const msg of chatMessages) {
|
||||||
|
const msgLang = msg.value.lang || 'ja'
|
||||||
|
chatLangs.add(msgLang)
|
||||||
|
if (msg.value.translations) {
|
||||||
|
for (const lang of Object.keys(msg.value.translations)) {
|
||||||
|
chatLangs.add(lang)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
langList = Array.from(chatLangs)
|
||||||
|
|
||||||
|
html += renderLangSelector(langList)
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Collect available languages from chat messages
|
||||||
|
const chatLangs = new Set<string>()
|
||||||
|
for (const msg of chatMessages) {
|
||||||
|
const msgLang = msg.value.lang || 'ja'
|
||||||
|
chatLangs.add(msgLang)
|
||||||
|
if (msg.value.translations) {
|
||||||
|
for (const lang of Object.keys(msg.value.translations)) {
|
||||||
|
chatLangs.add(lang)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
langList = Array.from(chatLangs)
|
||||||
|
|
||||||
|
html += renderLangSelector(langList)
|
||||||
|
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 +326,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 +348,8 @@ async function render(route: Route): Promise<void> {
|
|||||||
`
|
`
|
||||||
hideLoading(app)
|
hideLoading(app)
|
||||||
setupEventHandlers()
|
setupEventHandlers()
|
||||||
|
} finally {
|
||||||
|
isFirstRender = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,6 +389,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')
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,22 @@ 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
|
||||||
|
lang?: string
|
||||||
|
translations?: {
|
||||||
|
[lang: string]: {
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user